Merge branch 'redesign-login-form' into develop
This commit is contained in:
10
Cargo.toml
10
Cargo.toml
@@ -27,7 +27,17 @@ log = "0.4.20"
|
|||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
futures-util = "0.3.29"
|
futures-util = "0.3.29"
|
||||||
futures = "0.3.29"
|
futures = "0.3.29"
|
||||||
|
rand = "0.8.5"
|
||||||
|
reqwest = "0.11.24"
|
||||||
|
validator = { version = "0.17.0", features = ["derive"] }
|
||||||
|
const_format = "0.2.32"
|
||||||
|
zxcvbn = "2.2.2"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
target = "x86_64-unknown-linux-gnu"
|
target = "x86_64-unknown-linux-gnu"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
regex = "1.10.3"
|
||||||
|
|
||||||
|
[package.metadata.turf.class_names]
|
||||||
|
template = "<original_name>--<id>"
|
||||||
|
76
build.rs
76
build.rs
@@ -1,5 +1,81 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::io::{self, BufRead};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Tell Cargo to rerun this build script if any SCSS file
|
// Tell Cargo to rerun this build script if any SCSS file
|
||||||
// in the 'src' directory or its subdirectories changes.
|
// in the 'src' directory or its subdirectories changes.
|
||||||
println!("cargo:rerun-if-changed=src/**/*.scss");
|
println!("cargo:rerun-if-changed=src/**/*.scss");
|
||||||
|
|
||||||
|
let out_dir = env::var("OUT_DIR").unwrap();
|
||||||
|
|
||||||
|
let style_src_path = PathBuf::from("src/_base.scss");
|
||||||
|
let style_dst_path = Path::new(&out_dir).join("style_vars.rs");
|
||||||
|
|
||||||
|
export_color_variables(&style_src_path, &style_dst_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From https://doc.rust-lang.org/rust-by-example/std_misc/file/read_lines.html
|
||||||
|
// The output is wrapped in a Result to allow matching on errors.
|
||||||
|
// Returns an Iterator to the Reader of the lines of the file.
|
||||||
|
fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
let file = File::open(filename)?;
|
||||||
|
Ok(io::BufReader::new(file).lines())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CssColorVariable<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
value: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CssColorVariable<'a> {
|
||||||
|
pub fn to_rust(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"const {name}: &str = \"{value}\";",
|
||||||
|
name = self.name.replace('-', "_").to_uppercase(),
|
||||||
|
value = self.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_color_variables(src_path: &PathBuf, dst_path: &PathBuf) {
|
||||||
|
let mut dst_file = File::create(dst_path).unwrap();
|
||||||
|
if let Err(err) = dst_file.write(b"#[allow(dead_code)]\nmod style {") {
|
||||||
|
println!("{}", err);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let re = Regex::new(r"^\$([^:]+):[[:space:]]*#([^$]+);[[:space:]]*$").unwrap();
|
||||||
|
|
||||||
|
if let Ok(lines) = read_lines(src_path) {
|
||||||
|
for line in lines.map_while(Result::ok) {
|
||||||
|
let Some(groups) = re.captures(&line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let var = CssColorVariable {
|
||||||
|
name: &groups[1],
|
||||||
|
value: &groups[2],
|
||||||
|
};
|
||||||
|
|
||||||
|
let rust_export = var.to_rust();
|
||||||
|
if let Err(err) = dst_file.write_fmt(format_args!(" pub {}\n", rust_export)) {
|
||||||
|
println!("{}", err);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = dst_file.write(b"}\n") {
|
||||||
|
println!("{}", err);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
1
images/login-profile-placeholder.svg
Normal file
1
images/login-profile-placeholder.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" shape-rendering="auto"><desc>"Shapes" by "Florian Körner", licensed under "CC0 1.0". / Remix of the original. - Created with dicebear.com</desc><metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:rdf><cc:work><dc:title>Shapes</dc:title><dc:creator><cc:agent rdf:about="https://www.dicebear.com"><dc:title>Florian Körner</dc:title></cc:agent></dc:creator><dc:source>https://www.dicebear.com</dc:source><cc:license rdf:resource="https://creativecommons.org/publicdomain/zero/1.0/"></cc:license></cc:work></rdf:rdf></metadata><mask id="w6sj6i8m"><rect width="100" height="100" rx="0" ry="0" x="0" y="0" fill="#fff"></rect></mask><g mask="url(#w6sj6i8m)"><rect fill="#E2F2F7" width="100" height="100" x="0" y="0"></rect><g transform="matrix(1.2 0 0 1.2 -10 -10)"><g transform="translate(51, -23) rotate(-38 50 50)"><path d="M0 0h100v100H0V0Z" fill="#83CADE"></path></g></g><g transform="matrix(.8 0 0 .8 10 10)"><g transform="translate(-2, 35) rotate(99 50 50)"><path d="M100 50A50 50 0 1 1 0 50a50 50 0 0 1 100 0Z" fill="#6957A0"></path></g></g><g transform="matrix(.4 0 0 .4 30 30)"><g transform="translate(-18, -6) rotate(-97 50 50)"><path d="m50 7 50 86.6H0L50 7Z" fill="#D53583"></path></g></g></g></svg>
|
After Width: | Height: | Size: 1.4 KiB |
1
images/modal-default-critical-icon.svg
Normal file
1
images/modal-default-critical-icon.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 20 KiB |
1
images/modal-default-ok-icon.svg
Normal file
1
images/modal-default-ok-icon.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 17 KiB |
1
images/modal-default-warning-icon.svg
Normal file
1
images/modal-default-warning-icon.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 17 KiB |
14
images/wallpaper-pattern.svg
Normal file
14
images/wallpaper-pattern.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!-- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1152 128"> -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="128" width="384" viewBox="0 0 384 128">
|
||||||
|
<pattern id="p" width="384" height="128" patternUnits="userSpaceOnUse" stroke="#1B1B1B" stroke-linejoin="round" stroke-width="4">
|
||||||
|
<path fill="#1DB2CF" d="M 9.736 -15 L -30 3.337 l 23.642 -0.088 L -10.212 15 L 30 -3.425 H 6.834 L 9.736 -15 Z"/>
|
||||||
|
<path fill="#D53583" d="M 201.736 -15 L 162 3.337 l 23.642 -0.088 L 181.788 15 L 222 -3.425 H 198.834 L 201.736 -15 Z"/>
|
||||||
|
<path fill="#1DB2CF" d="M 393.736 -15 L 354 3.337 l 23.642 -0.088 L 373.788 15 L 414 -3.425 H 390.834 L 393.736 -15 Z"/>
|
||||||
|
<path fill="#7E6BB6" d="M 109.736 50 L 70 68.337 l 23.642 -0.088 L 89.788 80 L 130 61.575 H 106.834 L 109.736 50 Z"/>
|
||||||
|
<path fill="#7E6BB6" d="M 297.736 50 L 258 68.337 l 23.642 -0.088 L 277.788 80 L 318 61.575 H 294.834 L 297.736 50 Z"/>
|
||||||
|
<path fill="#1DB2CF" d="M 9.736 114 L -30 132.337 l 23.642 -0.088 L -10.212 144 L 30 125.575 H 6.834 L 9.736 114 Z"/>
|
||||||
|
<path fill="#D53583" d="M 201.736 114 L 162 132.337 l 23.642 -0.088 L 181.788 144 L 222 125.575 H 198.834 L 201.736 114 Z"/>
|
||||||
|
<path fill="#1DB2CF" d="M 393.736 114 L 354 132.337 l 23.642 -0.088 L 373.788 144 L 414 125.575 H 390.834 L 393.736 114 Z"/>
|
||||||
|
</pattern>
|
||||||
|
<rect fill="url(#p)" width="100%" height="100%"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
195
src/_base.scss
195
src/_base.scss
@@ -1,6 +1,9 @@
|
|||||||
|
@use "sass:map";
|
||||||
|
|
||||||
$font-size: 100vh * 0.01;
|
$font-size: 100vh * 0.01;
|
||||||
$icon-size: $font-size * 2;
|
$icon-size: $font-size * 2;
|
||||||
|
|
||||||
|
// TODO: To remove once the design updated.
|
||||||
$border-style: thin solid #BED6E0;
|
$border-style: thin solid #BED6E0;
|
||||||
|
|
||||||
$greyscale-90: #1B1B1B;
|
$greyscale-90: #1B1B1B;
|
||||||
@@ -26,29 +29,168 @@ $color-primary-70: #005A75;
|
|||||||
$color-primary-60: #004059;
|
$color-primary-60: #004059;
|
||||||
$color-primary-50: #002740;
|
$color-primary-50: #002740;
|
||||||
|
|
||||||
$color-secondary_150: #EAE5F3;
|
$color-secondary-150: #EAE5F3;
|
||||||
$color-secondary_140: #D5CCE7;
|
$color-secondary-140: #D5CCE7;
|
||||||
$color-secondary_130: #BFB3DB;
|
$color-secondary-130: #BFB3DB;
|
||||||
$color-secondary_120: #AA9BCF;
|
$color-secondary-120: #AA9BCF;
|
||||||
$color-secondary_110: #9583C3;
|
$color-secondary-110: #9583C3;
|
||||||
$color-secondary_100: #7E6BB6;
|
$color-secondary-100: #7E6BB6;
|
||||||
$color-secondary_90: #6957A0;
|
$color-secondary-90: #6957A0;
|
||||||
$color-secondary_80: #53448A;
|
$color-secondary-80: #53448A;
|
||||||
$color-secondary_70: #3E3174;
|
$color-secondary-70: #3E3174;
|
||||||
$color-secondary_60: #281F5F;
|
$color-secondary-60: #281F5F;
|
||||||
$color-secondary_50: #110E4B;
|
$color-secondary-50: #110E4B;
|
||||||
|
|
||||||
$color-ternary_150: #FCE0E9;
|
$color-ternary-150: #FCE0E9;
|
||||||
$color-ternary_140: #F7C1D4;
|
$color-ternary-140: #F7C1D4;
|
||||||
$color-ternary_130: #F1A1BF;
|
$color-ternary-130: #F1A1BF;
|
||||||
$color-ternary_120: #E981AA;
|
$color-ternary-120: #E981AA;
|
||||||
$color-ternary_110: #E05F96;
|
$color-ternary-110: #E05F96;
|
||||||
$color-ternary_100: #D53583;
|
$color-ternary-100: #D53583;
|
||||||
$color-ternary_90: #BC106D;
|
$color-ternary-90: #BC106D;
|
||||||
$color-ternary_80: #A30059;
|
$color-ternary-80: #A30059;
|
||||||
$color-ternary_70: #8B0046;
|
$color-ternary-70: #8B0046;
|
||||||
$color-ternary_60: #720033;
|
$color-ternary-60: #720033;
|
||||||
$color-ternary_50: #5A0022;
|
$color-ternary-50: #5A0022;
|
||||||
|
|
||||||
|
$color-critical-130: #F29088;
|
||||||
|
$color-critical-120: #E86F62;
|
||||||
|
$color-critical-110: #DA4B3C;
|
||||||
|
$color-critical-100: #C91B13;
|
||||||
|
$color-critical-90: #A9191A;
|
||||||
|
$color-critical-80: #88181C;
|
||||||
|
$color-critical-70: #68171B;
|
||||||
|
|
||||||
|
$color-success-130: #A6D3B1;
|
||||||
|
$color-success-120: #8AC59D;
|
||||||
|
$color-success-110: #6CB88A;
|
||||||
|
$color-success-100: #4AAB79;
|
||||||
|
$color-success-90: #418E63;
|
||||||
|
$color-success-80: #38724E;
|
||||||
|
$color-success-70: #2E573A;
|
||||||
|
|
||||||
|
$color-warning-130: #FFCCC0;
|
||||||
|
$color-warning-120: #FFBC9E;
|
||||||
|
$color-warning-110: #FFAC6A;
|
||||||
|
$color-warning-100: #FFA114;
|
||||||
|
$color-warning-90: #D18915;
|
||||||
|
$color-warning-80: #A67016;
|
||||||
|
$color-warning-70: #7D5816;
|
||||||
|
|
||||||
|
$colors: (
|
||||||
|
"greyscale": (
|
||||||
|
90: $greyscale-90,
|
||||||
|
80: $greyscale-80,
|
||||||
|
70: $greyscale-70,
|
||||||
|
60: $greyscale-60,
|
||||||
|
50: $greyscale-50,
|
||||||
|
40: $greyscale-40,
|
||||||
|
30: $greyscale-30,
|
||||||
|
20: $greyscale-20,
|
||||||
|
10: $greyscale-10,
|
||||||
|
0: $greyscale-0,
|
||||||
|
),
|
||||||
|
|
||||||
|
"primary": (
|
||||||
|
150: $color-primary-150,
|
||||||
|
140: $color-primary-140,
|
||||||
|
130: $color-primary-130,
|
||||||
|
120: $color-primary-120,
|
||||||
|
110: $color-primary-110,
|
||||||
|
100: $color-primary-100,
|
||||||
|
90: $color-primary-90,
|
||||||
|
80: $color-primary-80,
|
||||||
|
70: $color-primary-70,
|
||||||
|
60: $color-primary-60,
|
||||||
|
50: $color-primary-50,
|
||||||
|
),
|
||||||
|
|
||||||
|
"secondary": (
|
||||||
|
150: $color-secondary-150,
|
||||||
|
140: $color-secondary-140,
|
||||||
|
130: $color-secondary-130,
|
||||||
|
120: $color-secondary-120,
|
||||||
|
110: $color-secondary-110,
|
||||||
|
100: $color-secondary-100,
|
||||||
|
90: $color-secondary-90,
|
||||||
|
80: $color-secondary-80,
|
||||||
|
70: $color-secondary-70,
|
||||||
|
60: $color-secondary-60,
|
||||||
|
50: $color-secondary-50,
|
||||||
|
),
|
||||||
|
|
||||||
|
"ternary": (
|
||||||
|
150: $color-ternary-150,
|
||||||
|
140: $color-ternary-140,
|
||||||
|
130: $color-ternary-130,
|
||||||
|
120: $color-ternary-120,
|
||||||
|
110: $color-ternary-110,
|
||||||
|
100: $color-ternary-100,
|
||||||
|
90: $color-ternary-90,
|
||||||
|
80: $color-ternary-80,
|
||||||
|
70: $color-ternary-70,
|
||||||
|
60: $color-ternary-60,
|
||||||
|
50: $color-ternary-50,
|
||||||
|
),
|
||||||
|
|
||||||
|
"critical": (
|
||||||
|
130: $color-critical-130,
|
||||||
|
120: $color-critical-120,
|
||||||
|
110: $color-critical-110,
|
||||||
|
100: $color-critical-100,
|
||||||
|
90: $color-critical-90,
|
||||||
|
80: $color-critical-80,
|
||||||
|
70: $color-critical-70,
|
||||||
|
),
|
||||||
|
|
||||||
|
"success": (
|
||||||
|
130: $color-success-130,
|
||||||
|
120: $color-success-120,
|
||||||
|
110: $color-success-110,
|
||||||
|
100: $color-success-100,
|
||||||
|
90: $color-success-90,
|
||||||
|
80: $color-success-80,
|
||||||
|
70: $color-success-70,
|
||||||
|
),
|
||||||
|
|
||||||
|
"warning": (
|
||||||
|
130: $color-warning-130,
|
||||||
|
120: $color-warning-120,
|
||||||
|
110: $color-warning-110,
|
||||||
|
100: $color-warning-100,
|
||||||
|
90: $color-warning-90,
|
||||||
|
80: $color-warning-80,
|
||||||
|
70: $color-warning-70,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@function get-color($color-name, $color-level) {
|
||||||
|
$color: map.get($colors, $color-name);
|
||||||
|
@return map.get($color, $color-level);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$border-default-color: get-color(greyscale, 90);
|
||||||
|
$border-big-width: 4px;
|
||||||
|
$border-big: solid $border-big-width $border-default-color;
|
||||||
|
$border-normal-width: 2px;
|
||||||
|
$border-normal: solid $border-normal-width $border-default-color;
|
||||||
|
|
||||||
|
$form-max-height: 1024px;
|
||||||
|
$form-aspect-ratio: 1/1.618;
|
||||||
|
|
||||||
|
// TODO: Radius should be a percentage(eg: 1024/16px).
|
||||||
|
$border-radius: 16px;
|
||||||
|
|
||||||
|
$geist-font-path: "../fonts/Geist";
|
||||||
|
|
||||||
|
$transition-duration: 300ms;
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
src: url("#{$geist-font-path}/Geist-Bold.woff2") format("woff2");
|
||||||
|
font-family: "Geist";
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -56,14 +198,21 @@ body {
|
|||||||
margin: 0px;
|
margin: 0px;
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
outline: 0px;
|
outline: 0px;
|
||||||
font-family: Tahoma, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#main {
|
#main {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
font-family: "Geist";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: To remove once the design updated.
|
||||||
.aeroButton {
|
.aeroButton {
|
||||||
height: 50%;
|
height: 50%;
|
||||||
min-height: 16px;
|
min-height: 16px;
|
||||||
|
@@ -7,7 +7,7 @@ pub fn AvatarSelector(cx: Scope) -> Element {
|
|||||||
style { STYLE_SHEET },
|
style { STYLE_SHEET },
|
||||||
|
|
||||||
div {
|
div {
|
||||||
class: ClassName::SELECTOR,
|
class: ClassName::AVATAR_SELECTOR,
|
||||||
svg {
|
svg {
|
||||||
view_box: "0 0 100 100",
|
view_box: "0 0 100 100",
|
||||||
linearGradient {
|
linearGradient {
|
||||||
@@ -46,7 +46,7 @@ pub fn AvatarSelector(cx: Scope) -> Element {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
img {
|
img {
|
||||||
class: ClassName::PICTURE,
|
class: ClassName::AVATAR_SELECTOR_PICTURE,
|
||||||
src: "./images/default-avatar.png",
|
src: "./images/default-avatar.png",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
.selector {
|
.avatar-selector {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
|
|
||||||
.picture {
|
&__picture {
|
||||||
$height: 65%;
|
$height: 65%;
|
||||||
$margin: calc(100% - $height) / 2;
|
$margin: calc(100% - $height) / 2;
|
||||||
|
|
||||||
|
120
src/components/button.rs
Normal file
120
src/components/button.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::{Icon, IconShape};
|
||||||
|
|
||||||
|
turf::style_sheet!("src/components/button.scss");
|
||||||
|
|
||||||
|
macro_rules! svg_text_icon {
|
||||||
|
($name:ident,$text:literal) => {
|
||||||
|
struct $name;
|
||||||
|
impl IconShape for $name {
|
||||||
|
fn view_box(&self) -> String {
|
||||||
|
String::from("0 0 250 50")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xmlns(&self) -> String {
|
||||||
|
String::from("http://www.w3.org/2000/svg")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn child_elements(&self) -> LazyNodes {
|
||||||
|
rsx! {
|
||||||
|
text {
|
||||||
|
x: "50%",
|
||||||
|
y: "50%",
|
||||||
|
$text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! svg_text_button {
|
||||||
|
($name:ident,$style:ident,$icon:ident) => {
|
||||||
|
pub fn $name<'a>(cx: Scope<'a, ButtonProps>) -> Element<'a> {
|
||||||
|
cx.render(rsx! {
|
||||||
|
style { STYLE_SHEET },
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: cx.props.id.unwrap_or(""),
|
||||||
|
|
||||||
|
style: ClassName::$style,
|
||||||
|
|
||||||
|
onclick: |event| {
|
||||||
|
if let Some(cb) = &cx.props.onclick {
|
||||||
|
cb.call(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
focus: cx.props.focus,
|
||||||
|
|
||||||
|
Icon {
|
||||||
|
icon: $icon,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props)]
|
||||||
|
struct _ButtonProps<'a> {
|
||||||
|
children: Element<'a>,
|
||||||
|
#[props(default = false)]
|
||||||
|
focus: bool,
|
||||||
|
#[props(optional)]
|
||||||
|
id: Option<&'a str>,
|
||||||
|
#[props(optional)]
|
||||||
|
onclick: Option<EventHandler<'a, MouseEvent>>,
|
||||||
|
style: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn Button<'a>(cx: Scope<'a, _ButtonProps<'a>>) -> Element<'a> {
|
||||||
|
let focus = cx.props.focus;
|
||||||
|
|
||||||
|
cx.render(rsx! {
|
||||||
|
style { STYLE_SHEET },
|
||||||
|
|
||||||
|
button {
|
||||||
|
id: cx.props.id,
|
||||||
|
|
||||||
|
class: cx.props.style,
|
||||||
|
|
||||||
|
onmounted: move |cx| async move {
|
||||||
|
let _ = cx.inner().set_focus(focus).await;
|
||||||
|
},
|
||||||
|
|
||||||
|
onclick: move |evt| {
|
||||||
|
if let Some(cb) = &cx.props.onclick {
|
||||||
|
cb.call(evt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
&cx.props.children
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props)]
|
||||||
|
pub struct ButtonProps<'a> {
|
||||||
|
#[props(default = false)]
|
||||||
|
focus: bool,
|
||||||
|
#[props(optional)]
|
||||||
|
id: Option<&'a str>,
|
||||||
|
#[props(optional)]
|
||||||
|
onclick: Option<EventHandler<'a, MouseEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
svg_text_icon!(RegisterText, "REGISTER");
|
||||||
|
svg_text_button!(RegisterButton, REGISTER_BUTTON, RegisterText);
|
||||||
|
|
||||||
|
svg_text_icon!(LoginText, "LOGIN");
|
||||||
|
svg_text_button!(LoginButton, LOGIN_BUTTON, LoginText);
|
||||||
|
|
||||||
|
svg_text_icon!(SuccessText, "OK");
|
||||||
|
svg_text_button!(SuccessButton, SUCCESS_BUTTON, SuccessText);
|
||||||
|
|
||||||
|
svg_text_icon!(WarningText, "WARNING");
|
||||||
|
svg_text_button!(WarningButton, WARNING_BUTTON, WarningText);
|
||||||
|
|
||||||
|
svg_text_icon!(ErrorText, "ERROR");
|
||||||
|
svg_text_button!(ErrorButton, ERROR_BUTTON, ErrorText);
|
60
src/components/button.scss
Normal file
60
src/components/button.scss
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
@import "../_base.scss"
|
||||||
|
|
||||||
|
%button {
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 3.5;
|
||||||
|
|
||||||
|
border: $border-normal;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
|
||||||
|
color: get-color(greyscale, 0);
|
||||||
|
|
||||||
|
font-family: "Geist";
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 50;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: central;
|
||||||
|
fill: get-color(greyscale, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin button($color-name, $color-level) {
|
||||||
|
@extend %button;
|
||||||
|
|
||||||
|
background-color: get_color($color-name, $color-level);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: get_color($color-name, calc($color-level - 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: get_color($color-name, calc($color-level - 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-button {
|
||||||
|
@include button(ternary, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
@include button(secondary, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-button {
|
||||||
|
@include button(success, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-button {
|
||||||
|
@include button(warning, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-button {
|
||||||
|
@include button(critical, 100);
|
||||||
|
}
|
@@ -30,3 +30,9 @@ impl Interface {
|
|||||||
self.sender.send(Tasks::ToggleRoom(room_id))
|
self.sender.send(Tasks::ToggleRoom(room_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for Interface {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,9 +1,13 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus_free_icons::icons::md_navigation_icons::MdArrowDropDown;
|
use dioxus_free_icons::icons::md_navigation_icons::MdArrowDropDown;
|
||||||
use dioxus_free_icons::Icon;
|
use dioxus_free_icons::{Icon, IconShape};
|
||||||
|
|
||||||
turf::style_sheet!("src/components/icons.scss");
|
turf::style_sheet!("src/components/icons.scss");
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/style_vars.rs"));
|
||||||
|
|
||||||
|
use style::{COLOR_PRIMARY_100, COLOR_TERNARY_100};
|
||||||
|
|
||||||
pub fn DownArrowIcon(cx: Scope) -> Element {
|
pub fn DownArrowIcon(cx: Scope) -> Element {
|
||||||
cx.render(rsx! {
|
cx.render(rsx! {
|
||||||
style { STYLE_SHEET },
|
style { STYLE_SHEET },
|
||||||
@@ -14,3 +18,113 @@ pub fn DownArrowIcon(cx: Scope) -> Element {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _PYRAMID_OFFSET_X: f64 = 1.0;
|
||||||
|
const _PYRAMID_OFFSET_Y: f64 = 2.0;
|
||||||
|
const _PYRAMID_STROKE_WIDTH: f64 = 2.0;
|
||||||
|
|
||||||
|
const _PYRAMID_DIST_FROM_CENTRAL_X: f64 = 65.0;
|
||||||
|
const _PYRAMID_DIST_FROM_CENTRAL_E1_Y: f64 = 83.0;
|
||||||
|
|
||||||
|
const _PYRAMID_EDGES_E1_X: f64 = _PYRAMID_DIST_FROM_CENTRAL_X + _PYRAMID_OFFSET_X;
|
||||||
|
const _PYRAMID_EDGES_E1_Y: f64 = _PYRAMID_OFFSET_Y;
|
||||||
|
|
||||||
|
const _PYRAMID_LEFT_EDGE_E2_X: f64 = _PYRAMID_OFFSET_X;
|
||||||
|
const _PYRAMID_LEFT_EDGE_E2_Y: f64 = _PYRAMID_DIST_FROM_CENTRAL_E1_Y + _PYRAMID_OFFSET_Y;
|
||||||
|
|
||||||
|
const _PYRAMID_CENTRAL_EDGE_E2_X: f64 = _PYRAMID_DIST_FROM_CENTRAL_X + _PYRAMID_OFFSET_X;
|
||||||
|
const _PYRAMID_CENTRAL_EDGE_E2_Y: f64 = 100.0 + _PYRAMID_OFFSET_Y;
|
||||||
|
const _PYRAMID_CENTRAL_EDGE_Y_LEN: f64 = _PYRAMID_CENTRAL_EDGE_E2_Y - _PYRAMID_EDGES_E1_Y;
|
||||||
|
|
||||||
|
const _PYRAMID_RIGHT_EDGE_E2_X: f64 = 130.0 + _PYRAMID_OFFSET_X;
|
||||||
|
const _PYRAMID_RIGHT_EDGE_E2_Y: f64 = _PYRAMID_LEFT_EDGE_E2_Y;
|
||||||
|
|
||||||
|
struct PyramidShape<'a> {
|
||||||
|
color: &'a str,
|
||||||
|
ratio: f64,
|
||||||
|
progress_color: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> IconShape for PyramidShape<'a> {
|
||||||
|
fn view_box(&self) -> String {
|
||||||
|
let height = _PYRAMID_CENTRAL_EDGE_E2_Y + _PYRAMID_STROKE_WIDTH;
|
||||||
|
let width = _PYRAMID_RIGHT_EDGE_E2_X + _PYRAMID_STROKE_WIDTH;
|
||||||
|
format!("0 0 {width} {height}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xmlns(&self) -> String {
|
||||||
|
String::from("http://www.w3.org/2000/svg")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn child_elements(&self) -> LazyNodes {
|
||||||
|
let inverted_ratio = 1.0 - self.ratio;
|
||||||
|
|
||||||
|
let central_edge_ratio_e2_y =
|
||||||
|
_PYRAMID_CENTRAL_EDGE_Y_LEN * inverted_ratio + _PYRAMID_OFFSET_Y;
|
||||||
|
|
||||||
|
let left_edge_ratio_e1_x = _PYRAMID_OFFSET_X + (_PYRAMID_DIST_FROM_CENTRAL_X * self.ratio);
|
||||||
|
let right_edge_ratio_e1_x = _PYRAMID_OFFSET_X
|
||||||
|
+ _PYRAMID_EDGES_E1_X
|
||||||
|
+ (_PYRAMID_DIST_FROM_CENTRAL_X * inverted_ratio);
|
||||||
|
let no_central_edge_ratio_e1_y =
|
||||||
|
_PYRAMID_OFFSET_Y + (_PYRAMID_DIST_FROM_CENTRAL_E1_Y * inverted_ratio);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
g {
|
||||||
|
stroke: "#fff",
|
||||||
|
"stroke-linejoin": "round",
|
||||||
|
"stroke-width": _PYRAMID_STROKE_WIDTH,
|
||||||
|
fill: "#{self.progress_color}",
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: "#{self.color}",
|
||||||
|
d: "\
|
||||||
|
M {_PYRAMID_EDGES_E1_X} {_PYRAMID_EDGES_E1_Y} \
|
||||||
|
L {_PYRAMID_RIGHT_EDGE_E2_X} {_PYRAMID_RIGHT_EDGE_E2_Y} \
|
||||||
|
L {_PYRAMID_EDGES_E1_X} {_PYRAMID_CENTRAL_EDGE_E2_Y} \
|
||||||
|
M {_PYRAMID_EDGES_E1_X} {_PYRAMID_EDGES_E1_Y} \
|
||||||
|
L {_PYRAMID_LEFT_EDGE_E2_X} {_PYRAMID_LEFT_EDGE_E2_Y} \
|
||||||
|
L {_PYRAMID_EDGES_E1_X} {_PYRAMID_CENTRAL_EDGE_E2_Y} \
|
||||||
|
M {_PYRAMID_EDGES_E1_X} {_PYRAMID_EDGES_E1_Y} \
|
||||||
|
V {_PYRAMID_CENTRAL_EDGE_Y_LEN}",
|
||||||
|
},
|
||||||
|
path {
|
||||||
|
d: "\
|
||||||
|
M {_PYRAMID_CENTRAL_EDGE_E2_X} {_PYRAMID_CENTRAL_EDGE_E2_Y} \
|
||||||
|
V {central_edge_ratio_e2_y} \
|
||||||
|
L {left_edge_ratio_e1_x} {no_central_edge_ratio_e1_y} \
|
||||||
|
L {_PYRAMID_LEFT_EDGE_E2_X} {_PYRAMID_LEFT_EDGE_E2_Y} Z",
|
||||||
|
},
|
||||||
|
path {
|
||||||
|
d: "\
|
||||||
|
M {_PYRAMID_CENTRAL_EDGE_E2_X} {_PYRAMID_CENTRAL_EDGE_E2_Y} \
|
||||||
|
V {central_edge_ratio_e2_y} \
|
||||||
|
L {right_edge_ratio_e1_x} {no_central_edge_ratio_e1_y} \
|
||||||
|
L {_PYRAMID_RIGHT_EDGE_E2_X} {_PYRAMID_RIGHT_EDGE_E2_Y} Z",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props)]
|
||||||
|
pub struct PyramidProps<'a> {
|
||||||
|
#[props(default = 0.5)]
|
||||||
|
color: Option<&'a str>,
|
||||||
|
ratio: f64,
|
||||||
|
progress_color: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn Pyramid<'a>(cx: Scope<'a, PyramidProps<'a>>) -> Element<'a> {
|
||||||
|
let progress_color = cx.props.progress_color.unwrap_or(COLOR_PRIMARY_100);
|
||||||
|
let color = cx.props.color.unwrap_or(COLOR_TERNARY_100);
|
||||||
|
|
||||||
|
cx.render(rsx! {
|
||||||
|
style { STYLE_SHEET },
|
||||||
|
|
||||||
|
Icon {
|
||||||
|
class: ClassName::PYRAMID_ICON,
|
||||||
|
icon: PyramidShape { ratio: cx.props.ratio, color, progress_color },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@@ -5,3 +5,8 @@
|
|||||||
fill: white;
|
fill: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pyramid-icon {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::components::spinner::Spinner;
|
use super::spinner::Spinner;
|
||||||
use crate::components::wallpaper::Wallpaper;
|
use super::wallpaper::Wallpaper;
|
||||||
|
|
||||||
turf::style_sheet!("src/components/loading.scss");
|
turf::style_sheet!("src/components/loading.scss");
|
||||||
|
|
||||||
@@ -14,11 +14,14 @@ pub fn LoadingPage(cx: Scope) -> Element {
|
|||||||
style { STYLE_SHEET },
|
style { STYLE_SHEET },
|
||||||
|
|
||||||
div {
|
div {
|
||||||
class: ClassName::ROOT,
|
class: ClassName::LOADING,
|
||||||
|
|
||||||
Wallpaper {},
|
Wallpaper {},
|
||||||
|
|
||||||
Spinner {},
|
div {
|
||||||
|
class: ClassName::LOADING_SPINNER,
|
||||||
|
Spinner {},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
@import "../_base.scss"
|
@import "../_base.scss"
|
||||||
|
@import "./spinner.scss"
|
||||||
|
|
||||||
.root {
|
.loading {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@@ -8,6 +9,72 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
overflow: hidden;
|
&__spinner {
|
||||||
|
height: 5%;
|
||||||
|
aspect-ratio: $logo-aspect-ratio;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
$logo-center-pos: calc(50% + ($background-height / 2) - ($logo-height / 2));
|
||||||
|
|
||||||
|
@media (0px < height <= calc($background-height * 5)) {
|
||||||
|
top: $logo-center-pos;
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 5) < height <= calc($background-height * 6)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 1));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 6) < height <= calc($background-height * 8)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 2));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 8) < height <= calc($background-height * 10)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 3));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 10) < height <= calc($background-height * 12)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 4));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 12) < height <= calc($background-height * 14)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 5));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 14) < height <= calc($background-height * 16)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 6));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 16) < height <= calc($background-height * 18)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 7));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 18) < height <= calc($background-height * 20)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 8));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 20) < height <= calc($background-height * 22)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 9));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 22) < height <= calc($background-height * 24)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 10));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 24) < height <= calc($background-height * 26)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 11));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 26) < height <= calc($background-height * 28)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 12));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 28) < height <= calc($background-height * 30)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 13));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 30) < height <= calc($background-height * 32)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 14));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 32) < height <= calc($background-height * 34)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 15));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 34) < height <= calc($background-height * 36)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 16));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 36) < height <= calc($background-height * 38)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 17));
|
||||||
|
}
|
||||||
|
@media (calc($background-height * 38) < height <= calc($background-height * 40)) {
|
||||||
|
top: calc($logo-center-pos + ($background-height * 18));
|
||||||
|
}
|
||||||
|
|
||||||
|
background-color: get-color(greyscale, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,136 +1,890 @@
|
|||||||
use std::str::FromStr;
|
use std::borrow::Cow;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use const_format::formatcp;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use fermi::*;
|
use fermi::*;
|
||||||
use tracing::debug;
|
use rand::distributions::{Alphanumeric, DistString};
|
||||||
|
use tracing::{debug, error, warn};
|
||||||
|
use validator::{Validate, ValidateArgs, ValidateEmail, ValidationError, ValidationErrors};
|
||||||
|
use zxcvbn::zxcvbn;
|
||||||
|
|
||||||
use crate::base::SESSION;
|
use crate::base::{Session, SESSION};
|
||||||
use crate::components::avatar_selector::AvatarSelector;
|
|
||||||
use crate::components::header::Header;
|
use super::button::{LoginButton, RegisterButton};
|
||||||
|
use super::modal::{Modal, Severity};
|
||||||
|
use super::spinner::Spinner;
|
||||||
|
use super::text_input::{PasswordInputState, PasswordTextInput, TextInput, TextInputState};
|
||||||
|
|
||||||
|
use super::wallpaper::Wallpaper;
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/style_vars.rs"));
|
||||||
|
|
||||||
|
use style::{
|
||||||
|
COLOR_PRIMARY_100, COLOR_PRIMARY_110, COLOR_PRIMARY_120, COLOR_PRIMARY_140, COLOR_PRIMARY_150,
|
||||||
|
COLOR_PRIMARY_80, COLOR_PRIMARY_90, COLOR_SECONDARY_100, COLOR_SECONDARY_110,
|
||||||
|
COLOR_SECONDARY_120, COLOR_SECONDARY_140, COLOR_SECONDARY_150, COLOR_SECONDARY_80,
|
||||||
|
COLOR_SECONDARY_90, COLOR_TERNARY_100, COLOR_TERNARY_110, COLOR_TERNARY_120, COLOR_TERNARY_140,
|
||||||
|
COLOR_TERNARY_150, COLOR_TERNARY_80, COLOR_TERNARY_90,
|
||||||
|
};
|
||||||
|
|
||||||
turf::style_sheet!("src/components/login.scss");
|
turf::style_sheet!("src/components/login.scss");
|
||||||
|
|
||||||
static EMPTY_PLACEHOLDER: &str = "Tmp placeholder";
|
const BACKGROUND_COLORS_STR: &str = formatcp!(
|
||||||
|
"{COLOR_PRIMARY_150},{COLOR_PRIMARY_140},\
|
||||||
|
{COLOR_SECONDARY_150},{COLOR_SECONDARY_140},\
|
||||||
|
{COLOR_TERNARY_150},{COLOR_TERNARY_140}"
|
||||||
|
);
|
||||||
|
|
||||||
pub fn Login(cx: Scope) -> Element {
|
const SHAPE_1_COLORS_STR: &str = formatcp!(
|
||||||
|
"{COLOR_PRIMARY_120},{COLOR_PRIMARY_110},{COLOR_PRIMARY_100},{COLOR_PRIMARY_90},{COLOR_PRIMARY_80}"
|
||||||
|
);
|
||||||
|
|
||||||
|
const SHAPE_2_COLORS_STR: &str = formatcp!(
|
||||||
|
"{COLOR_SECONDARY_120},{COLOR_SECONDARY_110},{COLOR_SECONDARY_100},{COLOR_SECONDARY_90},{COLOR_SECONDARY_80}");
|
||||||
|
|
||||||
|
const SHAPE_3_COLORS_STR: &str = formatcp!(
|
||||||
|
"{COLOR_TERNARY_120},{COLOR_TERNARY_110},{COLOR_TERNARY_100},{COLOR_TERNARY_90},{COLOR_TERNARY_80}");
|
||||||
|
|
||||||
|
async fn generate_random_avatar(url: &String) -> Option<String> {
|
||||||
|
let seed = Alphanumeric.sample_string(&mut rand::thread_rng(), 16);
|
||||||
|
let req = format!(
|
||||||
|
"https://{url}/7.x/shapes/svg?\
|
||||||
|
seed={seed}&\
|
||||||
|
backgroundColor={BACKGROUND_COLORS_STR}&\
|
||||||
|
shape1Color={SHAPE_1_COLORS_STR}&\
|
||||||
|
shape2Color={SHAPE_2_COLORS_STR}&\
|
||||||
|
shape3Color={SHAPE_3_COLORS_STR}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut res: Option<String> = None;
|
||||||
|
match reqwest::get(req).await {
|
||||||
|
Ok(result) => {
|
||||||
|
match result.text().await {
|
||||||
|
Ok(svg) => {
|
||||||
|
res = Some(svg);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("Error during placeholder loading: {}", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("Error during placeholder loading: {}", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_ERROR_NAME: &str = "required";
|
||||||
|
const REQUIRED_ERROR_HELPER_TEXT: &str = "This field must be completed";
|
||||||
|
|
||||||
|
const INVALID_URL_ERROR_NAME: &str = "url";
|
||||||
|
const INVALID_URL_ERROR_HELPER_TEXT: &str = "This field must be a valid URL";
|
||||||
|
|
||||||
|
const INVALID_EMAIL_ERROR_NAME: &str = "email";
|
||||||
|
const INVALID_EMAIL_ERROR_HELPER_TEXT: &str = "This field must be a valid email";
|
||||||
|
|
||||||
|
const TOO_WEAK_PASSWORD_ERROR_NAME: &str = "too_weak_password";
|
||||||
|
const TOO_WEAK_PASSWORD_ERROR_HELPER_TEXT: &str = "The password is too weak";
|
||||||
|
|
||||||
|
const FIELDS_MISMATCH_ERROR_NAME: &str = "mismatch";
|
||||||
|
|
||||||
|
const HOMESERVER_FIELD_NAME: &str = "homeserver_url";
|
||||||
|
const ID_FIELD_NAME: &str = "id";
|
||||||
|
const PASSWORD_FIELD_NAME: &str = "password";
|
||||||
|
const CONFIRM_PASSWORD_FIELD_NAME: &str = "confirm password";
|
||||||
|
|
||||||
|
const LOGIN_ID_PLACEHOLDER: &str = "Username";
|
||||||
|
const REGISTER_ID_PLACEHOLDER: &str = "Email";
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
enum Process {
|
||||||
|
Login,
|
||||||
|
Registration,
|
||||||
|
}
|
||||||
|
|
||||||
|
trait OnValidationError {
|
||||||
|
fn on_validation_error(&self, error: &ValidationError) {
|
||||||
|
let code = error.code.to_string();
|
||||||
|
let msg = match code.as_str() {
|
||||||
|
REQUIRED_ERROR_NAME => Some(REQUIRED_ERROR_HELPER_TEXT),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(msg) = msg {
|
||||||
|
self.invalidate(msg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn reset(&self);
|
||||||
|
fn invalidate(&self, helper_text: String);
|
||||||
|
fn box_clone(&self) -> Box<dyn OnValidationError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for Box<dyn OnValidationError> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
self.box_clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TextInputHandler {
|
||||||
|
state_ref: UseRef<TextInputState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextInputHandler {}
|
||||||
|
|
||||||
|
impl OnValidationError for TextInputHandler {
|
||||||
|
fn reset(&self) {
|
||||||
|
self.state_ref.write().reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate(&self, helper_text: String) {
|
||||||
|
self.state_ref.write().invalidate(helper_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn box_clone(&self) -> Box<dyn OnValidationError> {
|
||||||
|
Box::new(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct UrlInputHandler {
|
||||||
|
state_ref: UseRef<TextInputState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UrlInputHandler {
|
||||||
|
pub fn new(state_ref: UseRef<TextInputState>) -> Self {
|
||||||
|
Self { state_ref }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OnValidationError for UrlInputHandler {
|
||||||
|
fn on_validation_error(&self, error: &ValidationError) {
|
||||||
|
let code = error.code.to_string();
|
||||||
|
let msg = match code.as_str() {
|
||||||
|
REQUIRED_ERROR_NAME => Some(REQUIRED_ERROR_HELPER_TEXT),
|
||||||
|
INVALID_URL_ERROR_NAME => Some(INVALID_URL_ERROR_HELPER_TEXT),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(msg) = msg {
|
||||||
|
self.invalidate(msg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&self) {
|
||||||
|
self.state_ref.write().reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate(&self, helper_text: String) {
|
||||||
|
self.state_ref.write().invalidate(helper_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn box_clone(&self) -> Box<dyn OnValidationError> {
|
||||||
|
Box::new(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct EmailInputHandler {
|
||||||
|
state_ref: UseRef<TextInputState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailInputHandler {
|
||||||
|
pub fn new(state_ref: UseRef<TextInputState>) -> Self {
|
||||||
|
Self { state_ref }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OnValidationError for EmailInputHandler {
|
||||||
|
fn on_validation_error(&self, error: &ValidationError) {
|
||||||
|
let code = error.code.to_string();
|
||||||
|
let msg = match code.as_str() {
|
||||||
|
REQUIRED_ERROR_NAME => Some(REQUIRED_ERROR_HELPER_TEXT),
|
||||||
|
INVALID_EMAIL_ERROR_NAME => Some(INVALID_EMAIL_ERROR_HELPER_TEXT),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(msg) = msg {
|
||||||
|
self.invalidate(msg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn reset(&self) {
|
||||||
|
self.state_ref.write().reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate(&self, helper_text: String) {
|
||||||
|
self.state_ref.write().invalidate(helper_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn box_clone(&self) -> Box<dyn OnValidationError> {
|
||||||
|
Box::new(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PasswordInputHandler {
|
||||||
|
state_ref: UseRef<PasswordInputState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasswordInputHandler {
|
||||||
|
pub fn new(state_ref: UseRef<PasswordInputState>) -> Self {
|
||||||
|
Self { state_ref }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OnValidationError for PasswordInputHandler {
|
||||||
|
fn on_validation_error(&self, error: &ValidationError) {
|
||||||
|
let code = error.code.to_string();
|
||||||
|
let msg = match code.as_str() {
|
||||||
|
REQUIRED_ERROR_NAME => Some(REQUIRED_ERROR_HELPER_TEXT),
|
||||||
|
INVALID_EMAIL_ERROR_NAME => Some(INVALID_EMAIL_ERROR_HELPER_TEXT),
|
||||||
|
TOO_WEAK_PASSWORD_ERROR_NAME => {
|
||||||
|
let mut score = 0.0;
|
||||||
|
if let Some(guesses_log10) = error.params.get("guesses_log10") {
|
||||||
|
if let Some(guesses_log10) = guesses_log10.as_f64() {
|
||||||
|
score = guesses_log10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.state_ref.write().score = score;
|
||||||
|
Some(TOO_WEAK_PASSWORD_ERROR_HELPER_TEXT)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(msg) = msg {
|
||||||
|
self.invalidate(msg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&self) {
|
||||||
|
self.state_ref.write().reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate(&self, helper_text: String) {
|
||||||
|
self.state_ref.write().invalidate(helper_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn box_clone(&self) -> Box<dyn OnValidationError> {
|
||||||
|
Box::new(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_validation_errors(
|
||||||
|
field_errors: &HashMap<&str, &Vec<ValidationError>>,
|
||||||
|
handlers: &InputHandlers,
|
||||||
|
) {
|
||||||
|
for (field_name, errors) in field_errors {
|
||||||
|
if let Some(handler) = handlers.get(field_name) {
|
||||||
|
errors.iter().for_each(|e| handler.on_validation_error(e));
|
||||||
|
} else if *field_name == "__all__" {
|
||||||
|
for error in *errors {
|
||||||
|
let code = error.code.to_string();
|
||||||
|
match code.as_str() {
|
||||||
|
FIELDS_MISMATCH_ERROR_NAME => {
|
||||||
|
let values = error.params.values();
|
||||||
|
|
||||||
|
let field_names = values
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for field_name in field_names.iter() {
|
||||||
|
if let Some(handler) = handlers.get(field_name) {
|
||||||
|
let other_field_names = field_names
|
||||||
|
.iter()
|
||||||
|
.filter(|f| **f != *field_name)
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let other_field_names_len = other_field_names.len();
|
||||||
|
|
||||||
|
let formatted = format!(
|
||||||
|
"This field must match with {}{}{} field{}",
|
||||||
|
other_field_names[0..other_field_names_len - 1].join(", "),
|
||||||
|
if other_field_names_len > 1 {
|
||||||
|
" and "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
other_field_names[other_field_names_len - 1],
|
||||||
|
if other_field_names_len > 1 { "s" } else { "" },
|
||||||
|
);
|
||||||
|
|
||||||
|
handler.invalidate(formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
REQUIRED_ERROR_NAME => {
|
||||||
|
let Some(field_value) = error.params.get("field_name") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(field_name) = field_value.as_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(handler) = handlers.get(field_name) {
|
||||||
|
handler.on_validation_error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => todo!("{:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!("No validation state found for \"{field_name}\" field name");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Validate)]
|
||||||
|
#[validate(context = "Process")]
|
||||||
|
#[validate(schema(
|
||||||
|
function = validate_data,
|
||||||
|
use_context,
|
||||||
|
skip_on_field_errors = false
|
||||||
|
))]
|
||||||
|
struct Data {
|
||||||
|
#[validate(required, url)]
|
||||||
|
homeserver_url: Option<String>,
|
||||||
|
#[validate(required, custom(function = validate_id, use_context))]
|
||||||
|
id: Option<String>,
|
||||||
|
#[validate(required, custom(function = validate_password, use_context))]
|
||||||
|
password: Option<String>,
|
||||||
|
confirm_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Data {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
homeserver_url: None,
|
||||||
|
id: None,
|
||||||
|
password: None,
|
||||||
|
confirm_password: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_data(data: &Data, process: &Process) -> Result<(), ValidationError> {
|
||||||
|
match process {
|
||||||
|
Process::Registration => {
|
||||||
|
let mut is_confirm_password_empty = true;
|
||||||
|
if let Some(confirm_password) = &data.confirm_password {
|
||||||
|
if !confirm_password.is_empty() {
|
||||||
|
is_confirm_password_empty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_confirm_password_empty {
|
||||||
|
let mut err = ValidationError::new(REQUIRED_ERROR_NAME);
|
||||||
|
err.add_param(Cow::from("field_name"), &CONFIRM_PASSWORD_FIELD_NAME);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.password != data.confirm_password {
|
||||||
|
let mut err = ValidationError::new(FIELDS_MISMATCH_ERROR_NAME);
|
||||||
|
err.add_param(Cow::from("field_name_1"), &PASSWORD_FIELD_NAME);
|
||||||
|
err.add_param(Cow::from("field_name_2"), &CONFIRM_PASSWORD_FIELD_NAME);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Process::Login => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_id(id: &Option<String>, process: &Process) -> Result<(), ValidationError> {
|
||||||
|
if *process == Process::Registration && !id.validate_email() {
|
||||||
|
let err = ValidationError::new(INVALID_EMAIL_ERROR_NAME);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PasswordValidationResult {
|
||||||
|
score: u8,
|
||||||
|
rating: f64, // 0 <= rating <= 1
|
||||||
|
suggestions: Vec<String>,
|
||||||
|
}
|
||||||
|
impl PasswordValidationResult {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
PasswordValidationResult {
|
||||||
|
score: 0,
|
||||||
|
rating: 0.0,
|
||||||
|
suggestions: Vec::<String>::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_password_score(
|
||||||
|
password: &str,
|
||||||
|
with_suggestions: Option<bool>,
|
||||||
|
) -> Option<PasswordValidationResult> {
|
||||||
|
let Ok(estimate) = zxcvbn(password, &[]) else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut result = PasswordValidationResult::new();
|
||||||
|
result.score = estimate.score();
|
||||||
|
|
||||||
|
let mut rating = estimate.guesses_log10() * 4.5 / 100.0;
|
||||||
|
if rating > 1.0 {
|
||||||
|
rating = 1.0;
|
||||||
|
};
|
||||||
|
result.rating = rating;
|
||||||
|
|
||||||
|
let with_suggestions = with_suggestions.unwrap_or(false);
|
||||||
|
if with_suggestions {
|
||||||
|
if let Some(feedback) = estimate.feedback() {
|
||||||
|
for suggestion in feedback.suggestions() {
|
||||||
|
result.suggestions.push(suggestion.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_password(password: &Option<String>, process: &Process) -> Result<(), ValidationError> {
|
||||||
|
if *process == Process::Registration {
|
||||||
|
if let Some(password) = password {
|
||||||
|
if let Some(result) = compute_password_score(password, Some(true)) {
|
||||||
|
// TODO: To configuration?
|
||||||
|
if result.score <= 2 {
|
||||||
|
let mut err = ValidationError::new(TOO_WEAK_PASSWORD_ERROR_NAME);
|
||||||
|
err.add_param(Cow::from("score"), &result.score);
|
||||||
|
err.add_param(Cow::from("rating"), &result.rating);
|
||||||
|
err.add_param(Cow::from("suggestions"), &result.suggestions);
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!("Unable to compute the password score");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("Password parameter is None");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_login(
|
||||||
|
session_ref: &UseAtomRef<Session>,
|
||||||
|
data_ref: &UseRef<Data>,
|
||||||
|
) -> Result<(), ValidationErrors> {
|
||||||
|
let login = data_ref.read();
|
||||||
|
|
||||||
|
match login.validate_with_args(&Process::Login) {
|
||||||
|
Ok(_) => {
|
||||||
|
session_ref.write().update(
|
||||||
|
login.homeserver_url.clone(),
|
||||||
|
login.id.clone(),
|
||||||
|
login.password.clone(),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_register(
|
||||||
|
_session_ref: &UseAtomRef<Session>,
|
||||||
|
data_ref: &UseRef<Data>,
|
||||||
|
) -> Result<(), ValidationErrors> {
|
||||||
|
let login = data_ref.read();
|
||||||
|
|
||||||
|
match login.validate_with_args(&Process::Registration) {
|
||||||
|
Ok(_) => {
|
||||||
|
error!("TODO: Manage registration process");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct InputHandlers<'a> {
|
||||||
|
handlers: HashMap<&'a str, Box<dyn OnValidationError>>,
|
||||||
|
}
|
||||||
|
impl<'a> InputHandlers<'a> {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
handlers: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn insert<T: 'static + OnValidationError>(&mut self, name: &'a str, handler: T) {
|
||||||
|
self.handlers.insert(name, Box::new(handler));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, name: &'a str) -> Option<&dyn OnValidationError> {
|
||||||
|
if let Some(handler) = self.handlers.get(name) {
|
||||||
|
return Some(handler.as_ref());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn reset_handlers(&self) {
|
||||||
|
self.handlers.values().for_each(|h| h.reset());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! on_input {
|
||||||
|
($data_ref:ident, $data_field:ident) => {
|
||||||
|
move |evt: FormEvent| {
|
||||||
|
$data_ref.write().$data_field = if evt.value.len() > 0 {
|
||||||
|
Some(evt.value.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! refresh_password_state {
|
||||||
|
($data:ident, $data_field:ident, $state:ident) => {
|
||||||
|
let mut rating = 0.0;
|
||||||
|
if let Some(password) = &$data.$data_field {
|
||||||
|
if let Some(result) = compute_password_score(password, None) {
|
||||||
|
rating = result.rating;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$state.write_silent().score = rating;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_modal<'a, 'b>(
|
||||||
|
config: &'b PasswordSuggestionsModalConfig<'a>,
|
||||||
|
on_confirm: impl Fn(Event<MouseData>) + 'b,
|
||||||
|
) -> LazyNodes<'a, 'b>
|
||||||
|
where
|
||||||
|
'b: 'a,
|
||||||
|
{
|
||||||
|
let suggestions = config.suggestions.get(PASSWORD_FIELD_NAME);
|
||||||
|
|
||||||
|
let mut rendered_suggestions = Vec::<LazyNodes>::new();
|
||||||
|
if let Some(suggestions) = suggestions {
|
||||||
|
if suggestions.len() == 1 {
|
||||||
|
rendered_suggestions.push(rsx!(suggestions[0].as_str()));
|
||||||
|
} else {
|
||||||
|
suggestions
|
||||||
|
.iter()
|
||||||
|
.for_each(|s| rendered_suggestions.push(rsx!(li { s.as_str() })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
Modal {
|
||||||
|
severity: config.severity.clone(),
|
||||||
|
title: config.title.as_ref(),
|
||||||
|
on_confirm: on_confirm,
|
||||||
|
|
||||||
|
div {
|
||||||
|
rendered_suggestions.into_iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Suggestions<'a> = HashMap<&'a str, Vec<String>>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PasswordSuggestionsModalConfig<'a> {
|
||||||
|
severity: Severity,
|
||||||
|
title: String,
|
||||||
|
suggestions: Suggestions<'a>,
|
||||||
|
}
|
||||||
|
impl<'a> PasswordSuggestionsModalConfig<'a> {
|
||||||
|
fn new(severity: Option<Severity>, title: String) -> Self {
|
||||||
|
Self {
|
||||||
|
severity: severity.unwrap_or(Severity::Critical),
|
||||||
|
title,
|
||||||
|
suggestions: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props)]
|
||||||
|
pub struct LoginProps<'a> {
|
||||||
|
dicebear_hostname: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn Login<'a>(cx: Scope<'a, LoginProps>) -> Element<'a> {
|
||||||
debug!("Login rendering");
|
debug!("Login rendering");
|
||||||
|
|
||||||
let session = use_atom_ref(cx, &SESSION);
|
let session = use_atom_ref(cx, &SESSION);
|
||||||
|
|
||||||
let invalid_login = use_state(cx, || false);
|
let data_ref = use_ref(cx, Data::new);
|
||||||
|
|
||||||
let login = use_ref(cx, Login::new);
|
let current_process = use_state(cx, || Process::Login);
|
||||||
|
|
||||||
let password_class = if **invalid_login {
|
let data = data_ref.read();
|
||||||
ClassName::INVALID_INPUT
|
|
||||||
} else {
|
let homeserver_url = data.homeserver_url.as_deref().unwrap_or("");
|
||||||
""
|
let homeserver_url_state = use_ref(cx, TextInputState::new);
|
||||||
|
let id = data.id.as_deref().unwrap_or("");
|
||||||
|
let id_state = use_ref(cx, TextInputState::new);
|
||||||
|
let password = data.password.as_deref().unwrap_or("");
|
||||||
|
let password_state = use_ref(cx, PasswordInputState::new);
|
||||||
|
let confirm_password = data.confirm_password.as_deref().unwrap_or("");
|
||||||
|
let confirm_password_state = use_ref(cx, PasswordInputState::new);
|
||||||
|
|
||||||
|
let mut handlers = InputHandlers::new();
|
||||||
|
handlers.insert(
|
||||||
|
HOMESERVER_FIELD_NAME,
|
||||||
|
UrlInputHandler::new(homeserver_url_state.clone()),
|
||||||
|
);
|
||||||
|
handlers.insert(ID_FIELD_NAME, EmailInputHandler::new(id_state.clone()));
|
||||||
|
handlers.insert(
|
||||||
|
PASSWORD_FIELD_NAME,
|
||||||
|
PasswordInputHandler::new(password_state.clone()),
|
||||||
|
);
|
||||||
|
handlers.insert(
|
||||||
|
CONFIRM_PASSWORD_FIELD_NAME,
|
||||||
|
PasswordInputHandler::new(confirm_password_state.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let spinner_animated = use_state(cx, || false);
|
||||||
|
let id_placeholder = use_state(cx, || LOGIN_ID_PLACEHOLDER);
|
||||||
|
|
||||||
|
let url = cx
|
||||||
|
.props
|
||||||
|
.dicebear_hostname
|
||||||
|
.unwrap_or("dicebear.tools.adrien.run")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let random_avatar_future =
|
||||||
|
use_future(
|
||||||
|
cx,
|
||||||
|
&url,
|
||||||
|
|url| async move { generate_random_avatar(&url).await },
|
||||||
|
);
|
||||||
|
|
||||||
|
let avatar = match random_avatar_future.value() {
|
||||||
|
Some(Some(svg)) => {
|
||||||
|
rsx!(div {
|
||||||
|
class: ClassName::LOGIN_FORM_PHOTO_CONTENT,
|
||||||
|
dangerous_inner_html: svg.as_str(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some(None) | None => {
|
||||||
|
warn!("No profile image set or generated, display the placeholder");
|
||||||
|
rsx!(div {
|
||||||
|
class: ClassName::LOGIN_FORM_PHOTO_CONTENT,
|
||||||
|
img {
|
||||||
|
src: "./images/login-profile-placeholder.svg"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let run_matrix_client = move |_| {
|
if **spinner_animated && session.read().is_logged {
|
||||||
cx.spawn({
|
debug!("Stop spinner");
|
||||||
|
spinner_animated.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
to_owned![session, login];
|
refresh_password_state!(data, password, password_state);
|
||||||
|
refresh_password_state!(data, confirm_password, confirm_password_state);
|
||||||
|
|
||||||
async move {
|
let modal_configs = use_ref(cx, Vec::<PasswordSuggestionsModalConfig>::new);
|
||||||
let login_ref = login.read();
|
let modal_config = use_state(cx, || None::<PasswordSuggestionsModalConfig>);
|
||||||
|
|
||||||
session.write().update(
|
if modal_configs.read().len() > 0 && modal_config.is_none() {
|
||||||
login_ref.homeserver_url.clone(),
|
modal_config.set(modal_configs.write_silent().pop());
|
||||||
login_ref.email.clone(),
|
}
|
||||||
login_ref.password.clone(),
|
|
||||||
);
|
let on_clicked_login = {
|
||||||
|
to_owned![handlers];
|
||||||
|
|
||||||
|
move |_| {
|
||||||
|
|
||||||
|
handlers.reset_handlers();
|
||||||
|
|
||||||
|
if **current_process == Process::Registration {
|
||||||
|
current_process.set(Process::Login);
|
||||||
|
data_ref.write().id = None;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
spinner_animated.set(true);
|
||||||
|
|
||||||
|
if let Err(errors) = on_login(session, data_ref) {
|
||||||
|
let field_errors = errors.field_errors();
|
||||||
|
on_validation_errors(&field_errors, &handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner_animated.set(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let login_ref = login.read();
|
let on_clicked_register = {
|
||||||
let placeholder = EMPTY_PLACEHOLDER.to_string();
|
to_owned![handlers, modal_configs];
|
||||||
let homeserver_url_value = login_ref.homeserver_url.as_ref().unwrap_or(&placeholder);
|
|
||||||
let email_value = login_ref.email.as_ref().unwrap_or(&placeholder);
|
move |_| {
|
||||||
let password_value = login_ref.password.as_ref().unwrap_or(&placeholder);
|
if **current_process == Process::Login {
|
||||||
|
current_process.set(Process::Registration);
|
||||||
|
data_ref.write().id = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.reset_handlers();
|
||||||
|
|
||||||
|
spinner_animated.set(true);
|
||||||
|
|
||||||
|
if let Err(errors) = on_register(session, data_ref) {
|
||||||
|
let field_name = PASSWORD_FIELD_NAME;
|
||||||
|
|
||||||
|
let field_errors = errors.field_errors();
|
||||||
|
if let Some(errors) = field_errors.get(field_name) {
|
||||||
|
let mut suggestions_msgs = Vec::<String>::new();
|
||||||
|
errors
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.code == TOO_WEAK_PASSWORD_ERROR_NAME)
|
||||||
|
.for_each(|e| {
|
||||||
|
if let Some(suggestions_value) = e.params.get("suggestions") {
|
||||||
|
if let Some(suggestion) = suggestions_value.as_array() {
|
||||||
|
let msgs = suggestion.iter().map(|msg| {
|
||||||
|
let msg = msg.to_string();
|
||||||
|
let without_quote_marks = &msg[1..msg.len() - 1];
|
||||||
|
without_quote_marks.to_string()
|
||||||
|
});
|
||||||
|
suggestions_msgs.extend(msgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if !suggestions_msgs.is_empty() {
|
||||||
|
let mut modal_config = PasswordSuggestionsModalConfig::new(
|
||||||
|
None,
|
||||||
|
"Let's talk about your password".to_string(),
|
||||||
|
);
|
||||||
|
modal_config
|
||||||
|
.suggestions
|
||||||
|
.insert(field_name, suggestions_msgs);
|
||||||
|
|
||||||
|
modal_configs.write().push(modal_config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on_validation_errors(&field_errors, &handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner_animated.set(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut form_classes: [&str; 2] = [ClassName::LOGIN_FORM, ""];
|
||||||
|
let mut password_classes: [&str; 2] = [ClassName::LOGIN_FORM_PASSWORD, ""];
|
||||||
|
let mut confirm_password_classes: [&str; 2] = [ClassName::LOGIN_FORM_CONFIRM_PASSWORD, ""];
|
||||||
|
|
||||||
|
match **current_process {
|
||||||
|
Process::Registration => {
|
||||||
|
form_classes[1] = ClassName::REGISTER;
|
||||||
|
password_classes[1] = ClassName::SHOW;
|
||||||
|
confirm_password_classes[1] = ClassName::SHOW;
|
||||||
|
|
||||||
|
if **id_placeholder != REGISTER_ID_PLACEHOLDER {
|
||||||
|
id_placeholder.set(REGISTER_ID_PLACEHOLDER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Process::Login => {
|
||||||
|
if **id_placeholder != LOGIN_ID_PLACEHOLDER {
|
||||||
|
id_placeholder.set(LOGIN_ID_PLACEHOLDER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_modal_confirm = move |_: Event<MouseData>| {
|
||||||
|
modal_config.set(None);
|
||||||
|
};
|
||||||
|
let rendered_modal = modal_config
|
||||||
|
.get()
|
||||||
|
.as_ref()
|
||||||
|
.map(|modal_config| render!(generate_modal(modal_config, on_modal_confirm)));
|
||||||
|
|
||||||
|
let form_classes_str = form_classes.join(" ");
|
||||||
|
let password_classes_str = password_classes.join(" ");
|
||||||
|
let confirm_password_classes_str = confirm_password_classes.join(" ");
|
||||||
|
|
||||||
cx.render(rsx! {
|
cx.render(rsx! {
|
||||||
style { STYLE_SHEET },
|
style { STYLE_SHEET },
|
||||||
|
|
||||||
|
Wallpaper {},
|
||||||
|
|
||||||
div {
|
div {
|
||||||
class: ClassName::ROOT,
|
class: ClassName::LOGIN,
|
||||||
|
|
||||||
div {
|
div {
|
||||||
class: ClassName::HEADER,
|
class: "{form_classes_str}",
|
||||||
Header {},
|
|
||||||
},
|
|
||||||
|
|
||||||
div {
|
|
||||||
class: ClassName::BODY,
|
|
||||||
div {
|
div {
|
||||||
class: ClassName::AVATAR_SELECTOR_CONTAINER,
|
class: ClassName::LOGIN_FORM_PHOTO,
|
||||||
AvatarSelector {},
|
|
||||||
|
onclick: move |_| {
|
||||||
|
random_avatar_future.restart()
|
||||||
|
},
|
||||||
|
|
||||||
|
{avatar},
|
||||||
},
|
},
|
||||||
|
|
||||||
p {
|
div {
|
||||||
"Matrix homeserver:"
|
class: ClassName::LOGIN_FORM_HOMESERVER,
|
||||||
},
|
TextInput {
|
||||||
input {
|
placeholder: "Homeserver URL",
|
||||||
id: "input-homeserver-url",
|
value: "{homeserver_url}",
|
||||||
r#type: "text",
|
state: homeserver_url_state,
|
||||||
name: "homeserver URL",
|
oninput: on_input![data_ref, homeserver_url],
|
||||||
value: "{homeserver_url_value}",
|
|
||||||
oninput: move |evt| login.write().homeserver_url = Some(evt.value.clone()),
|
|
||||||
},
|
|
||||||
|
|
||||||
p {
|
|
||||||
"E-mail address:"
|
|
||||||
},
|
|
||||||
input {
|
|
||||||
id: "login-input-email",
|
|
||||||
r#type: "text",
|
|
||||||
name: "email",
|
|
||||||
value: "{email_value}",
|
|
||||||
oninput: move |evt| login.write().email = Some(evt.value.clone()),
|
|
||||||
},
|
|
||||||
p {
|
|
||||||
"Password:"
|
|
||||||
},
|
|
||||||
input {
|
|
||||||
class: "{password_class}",
|
|
||||||
id: "login-input-password",
|
|
||||||
r#type: "password",
|
|
||||||
name: "Password",
|
|
||||||
value: "{password_value}",
|
|
||||||
oninput: move |evt| {
|
|
||||||
login.write().password = Some(evt.value.clone());
|
|
||||||
invalid_login.set(false);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
div {
|
div {
|
||||||
class: ClassName::FOOTER_BUTTONS,
|
class: ClassName::LOGIN_FORM_ID,
|
||||||
input {
|
TextInput {
|
||||||
class: ClassName::BUTTON,
|
placeholder: "{id_placeholder}",
|
||||||
onclick: run_matrix_client,
|
value: "{id}",
|
||||||
r#type: "submit",
|
state: id_state,
|
||||||
value: "sign in",
|
oninput: on_input![data_ref, id],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: "{password_classes_str}",
|
||||||
|
PasswordTextInput {
|
||||||
|
placeholder: "Password",
|
||||||
|
value: "{password}",
|
||||||
|
state: password_state,
|
||||||
|
oninput: on_input![data_ref, password],
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: "{confirm_password_classes_str}",
|
||||||
|
PasswordTextInput {
|
||||||
|
placeholder: "Confirm Password",
|
||||||
|
value: "{confirm_password}",
|
||||||
|
state: confirm_password_state,
|
||||||
|
oninput: on_input![data_ref, confirm_password],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::LOGIN_FORM_SPINNER,
|
||||||
|
Spinner {
|
||||||
|
animate: **spinner_animated,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::LOGIN_FORM_REGISTER_BUTTON,
|
||||||
|
RegisterButton {
|
||||||
|
onclick: on_clicked_register,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::LOGIN_FORM_LOGIN_BUTTON,
|
||||||
|
LoginButton {
|
||||||
|
focus: true,
|
||||||
|
onclick: on_clicked_login,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
rendered_modal,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Login {
|
|
||||||
homeserver_url: Option<String>,
|
|
||||||
email: Option<String>,
|
|
||||||
password: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Login {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
homeserver_url: None,
|
|
||||||
email: None,
|
|
||||||
password: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@@ -1,53 +1,135 @@
|
|||||||
@import "../_base.scss";
|
@import "../_base.scss"
|
||||||
|
@import "./spinner.scss"
|
||||||
|
|
||||||
.root {
|
.login {
|
||||||
width: 90%;
|
height: 100%;
|
||||||
height: 98%;
|
width: 100%;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
padding: 5%;
|
position: relative;
|
||||||
padding-top: 2%;
|
top: -100vh;
|
||||||
|
|
||||||
background: linear-gradient(rgb(138, 191, 209), rgb(236, 246, 249) 10%);
|
margin-bottom: -100vh;
|
||||||
|
|
||||||
.header {
|
&__form {
|
||||||
height: 5%;
|
$height: 95%;
|
||||||
width: 100%;
|
height: $height;
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
max-height: $form-max-height;
|
||||||
height: 50%;
|
aspect-ratio: $form-aspect-ratio;
|
||||||
width: 50%;
|
|
||||||
max-width: 400px;
|
|
||||||
|
|
||||||
display: flex;
|
border: $border-big;
|
||||||
flex-direction: column;
|
border-color: get-color(primary, 90);
|
||||||
justify-content: center;
|
border-radius: $border-radius;
|
||||||
|
|
||||||
padding-bottom: 3%;
|
background-color: get-color(greyscale, 0);
|
||||||
|
|
||||||
.invalidInput {
|
display: grid;
|
||||||
border-color: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-selector-container {
|
$padding-col: 5%;
|
||||||
height: 30%;
|
$button-height: 8%;
|
||||||
width: 100%;
|
$button-overlap: 5%;
|
||||||
|
$profile-img-width: 40%;
|
||||||
|
$edit-padding: 7.5%;
|
||||||
|
$photo-padding: 17.5%;
|
||||||
|
|
||||||
padding-left: 25%;
|
$padding-row: calc(5% * $form-aspect-ratio);
|
||||||
}
|
$spinner-col-width: calc(0% + ($button-overlap * 2));
|
||||||
|
$profile-img-height: calc($profile-img-width * $form-aspect-ratio);
|
||||||
|
$profile-img-ext-width: calc((($profile-img-width - $spinner-col-width) / 2) - $button-overlap);
|
||||||
|
$center-width: calc((50% - $padding-col - $edit-padding - $photo-padding - $button-overlap -
|
||||||
|
$profile-img-ext-width) * 2);
|
||||||
|
$spinner-height: calc(($spinner-col-width + $center-width) * $form-aspect-ratio / $logo-aspect-ratio);
|
||||||
|
|
||||||
|
grid-template-columns: $padding-col $edit-padding $photo-padding
|
||||||
|
$button-overlap $profile-img-ext-width $center-width $profile-img-ext-width $button-overlap
|
||||||
|
$photo-padding $edit-padding $padding-col;
|
||||||
|
grid-template-rows: $padding-row $profile-img-height auto 5% 5% 5% 5% 5% 0% 0% 8.5% $spinner-height 8.5% $button-height $padding-row;
|
||||||
|
grid-template-areas:
|
||||||
|
". . . . . . . . . . ."
|
||||||
|
". . . photo photo photo photo photo . . ."
|
||||||
|
". . . . . . . . . . ."
|
||||||
|
". . homeserver homeserver homeserver homeserver homeserver homeserver homeserver . ."
|
||||||
|
". . . . . . . . . . ."
|
||||||
|
". . id id id id id id id . ."
|
||||||
|
". . . . . . . . . . ."
|
||||||
|
". . password password password password password password password . ."
|
||||||
|
". . . . . . . . . . ."
|
||||||
|
". . confirm confirm confirm confirm confirm confirm confirm . ."
|
||||||
|
". . . . . . . . . . ."
|
||||||
|
". . . . spinner spinner spinner . . . ."
|
||||||
|
". . . . . . . . . . ."
|
||||||
|
". register register register register . login login login login ."
|
||||||
|
". . . . . . . . . . ."
|
||||||
|
;
|
||||||
|
|
||||||
.footerButtons {
|
transition: $transition-duration;
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
padding-top: 5%;
|
&.register {
|
||||||
|
grid-template-rows: $padding-row $profile-img-height auto 5% 5% 5% 5% 5% 5% 5% 5% $spinner-height 5% $button-height $padding-row;
|
||||||
|
}
|
||||||
|
|
||||||
display: flex;
|
&__photo {
|
||||||
justify-content: center;
|
grid-area: photo;
|
||||||
}
|
|
||||||
}
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
border: $border-normal;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
height: calc(100% + (2 * $border-normal-width));
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__homeserver {
|
||||||
|
grid-area: homeserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__id {
|
||||||
|
grid-area: id;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__password {
|
||||||
|
grid-area: password;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__confirm-password {
|
||||||
|
grid-area: confirm;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spinner {
|
||||||
|
grid-area: spinner;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__register-button {
|
||||||
|
grid-area: register;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__login-button {
|
||||||
|
grid-area: login;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,6 @@ use tracing::debug;
|
|||||||
|
|
||||||
use crate::base::SESSION;
|
use crate::base::SESSION;
|
||||||
use crate::components::contacts_window::ContactsWindow;
|
use crate::components::contacts_window::ContactsWindow;
|
||||||
use crate::components::login::Login;
|
|
||||||
|
|
||||||
pub fn MainWindow(cx: Scope) -> Element {
|
pub fn MainWindow(cx: Scope) -> Element {
|
||||||
debug!("MainWindow rendering");
|
debug!("MainWindow rendering");
|
||||||
@@ -16,8 +15,5 @@ pub fn MainWindow(cx: Scope) -> Element {
|
|||||||
if is_logged {
|
if is_logged {
|
||||||
rsx!(ContactsWindow {})
|
rsx!(ContactsWindow {})
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
rsx!(Login {})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
pub mod avatar_selector;
|
pub mod avatar_selector;
|
||||||
|
pub mod button;
|
||||||
pub mod chats_window;
|
pub mod chats_window;
|
||||||
pub mod contacts_window;
|
pub mod contacts_window;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
@@ -6,5 +7,7 @@ pub mod icons;
|
|||||||
pub mod loading;
|
pub mod loading;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod main_window;
|
pub mod main_window;
|
||||||
|
pub mod modal;
|
||||||
pub mod spinner;
|
pub mod spinner;
|
||||||
|
pub mod text_input;
|
||||||
pub mod wallpaper;
|
pub mod wallpaper;
|
||||||
|
229
src/components/modal.rs
Normal file
229
src/components/modal.rs
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use rand::distributions::{Alphanumeric, DistString};
|
||||||
|
use tracing::{error, warn};
|
||||||
|
|
||||||
|
use super::button::{ErrorButton, SuccessButton, WarningButton};
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/style_vars.rs"));
|
||||||
|
|
||||||
|
use style::{COLOR_CRITICAL_100, COLOR_SUCCESS_100, COLOR_WARNING_100};
|
||||||
|
|
||||||
|
turf::style_sheet!("src/components/modal.scss");
|
||||||
|
|
||||||
|
#[derive(Clone, Eq, PartialEq, Hash)]
|
||||||
|
pub enum Severity {
|
||||||
|
Ok,
|
||||||
|
Warning,
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
impl fmt::Display for Severity {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
let repr = match self {
|
||||||
|
Self::Ok => "Severity::Ok",
|
||||||
|
Self::Warning => "Severity::Warning",
|
||||||
|
Self::Critical => "Severity::Critical",
|
||||||
|
};
|
||||||
|
write!(f, "{repr}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DicebearConfig<'a> {
|
||||||
|
gesture: &'a str,
|
||||||
|
color: &'a str,
|
||||||
|
browns: Vec<u32>,
|
||||||
|
eyes: Vec<u32>,
|
||||||
|
lips: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props)]
|
||||||
|
pub struct ModalProps<'a> {
|
||||||
|
pub severity: Severity,
|
||||||
|
#[props(optional)]
|
||||||
|
pub title: Option<&'a str>,
|
||||||
|
pub children: Element<'a>,
|
||||||
|
#[props(optional)]
|
||||||
|
pub on_confirm: Option<EventHandler<'a, MouseEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dicebear_variants() -> &'static HashMap<Severity, DicebearConfig<'static>> {
|
||||||
|
static HASHMAP: OnceLock<HashMap<Severity, DicebearConfig>> = OnceLock::new();
|
||||||
|
HASHMAP.get_or_init(|| {
|
||||||
|
let mut variants = HashMap::new();
|
||||||
|
variants.insert(
|
||||||
|
Severity::Critical,
|
||||||
|
DicebearConfig {
|
||||||
|
gesture: "wavePointLongArms",
|
||||||
|
color: COLOR_CRITICAL_100,
|
||||||
|
browns: vec![2, 6, 11, 13],
|
||||||
|
eyes: vec![2, 4],
|
||||||
|
lips: vec![1, 2, 7, 11, 19, 20, 24, 27],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
variants.insert(
|
||||||
|
Severity::Warning,
|
||||||
|
DicebearConfig {
|
||||||
|
gesture: "pointLongArm",
|
||||||
|
color: COLOR_WARNING_100,
|
||||||
|
browns: vec![2, 5, 10, 13],
|
||||||
|
eyes: vec![1, 3],
|
||||||
|
lips: vec![1, 2, 4, 8, 10, 13, 18, 21, 29],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
variants.insert(
|
||||||
|
Severity::Ok,
|
||||||
|
DicebearConfig {
|
||||||
|
gesture: "okLongArm",
|
||||||
|
color: COLOR_SUCCESS_100,
|
||||||
|
browns: vec![1, 3, 4, 7, 8, 9, 12],
|
||||||
|
eyes: vec![5],
|
||||||
|
lips: vec![3, 5, 9, 14, 17, 22, 23, 25, 30],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
variants
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_dicebear_variants(values: &[u32]) -> String {
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.map(|l| format!("variant{:02}", l))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_random_figure(url: &String, severity: Severity) -> Option<String> {
|
||||||
|
let mut res: Option<String> = None;
|
||||||
|
|
||||||
|
let config = match dicebear_variants().get(&severity) {
|
||||||
|
Some(config) => config,
|
||||||
|
None => {
|
||||||
|
error!("No dicebear configuration found for \"{severity}\"");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let seed = Alphanumeric.sample_string(&mut rand::thread_rng(), 16);
|
||||||
|
|
||||||
|
let color = config.color;
|
||||||
|
let gesture = config.gesture;
|
||||||
|
let rendered_browns = render_dicebear_variants(&config.browns);
|
||||||
|
let rendered_eyes = render_dicebear_variants(&config.eyes);
|
||||||
|
let rendered_lips = render_dicebear_variants(&config.lips);
|
||||||
|
|
||||||
|
let req = format!(
|
||||||
|
"https://{url}/7.x/notionists/svg?\
|
||||||
|
seed={seed}&\
|
||||||
|
backgroundColor={color}&\
|
||||||
|
gestureProbability=100&gesture={gesture}&\
|
||||||
|
browsProbability=100&brows={rendered_browns}&\
|
||||||
|
eyesProbability=100&eyes={rendered_eyes}&\
|
||||||
|
lipsProbability=100&lips={rendered_lips}"
|
||||||
|
);
|
||||||
|
|
||||||
|
match reqwest::get(req).await {
|
||||||
|
Ok(result) => {
|
||||||
|
match result.text().await {
|
||||||
|
Ok(svg) => {
|
||||||
|
res = Some(svg);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("Error during placeholder loading: {}", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("Error during placeholder loading: {}", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Modal<'a>(cx: Scope<'a, ModalProps<'a>>) -> Element<'a> {
|
||||||
|
// TODO: Use configuration file
|
||||||
|
let url = "dicebear.tools.adrien.run".to_string();
|
||||||
|
|
||||||
|
let severity = cx.props.severity.clone();
|
||||||
|
|
||||||
|
let random_figure_future = use_future(cx, &url, |url| async move {
|
||||||
|
generate_random_figure(&url, severity).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let figure = match random_figure_future.value() {
|
||||||
|
Some(Some(svg)) => Some(rsx! {
|
||||||
|
div {
|
||||||
|
class: ClassName::MODAL_CONTENT_ICON_PLACEHOLDER,
|
||||||
|
dangerous_inner_html: svg.as_str(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Some(None) => {
|
||||||
|
warn!("No profile image set or generated, display the placeholder");
|
||||||
|
let path = match cx.props.severity {
|
||||||
|
Severity::Ok => "./images/modal-default-ok-icon.svg",
|
||||||
|
Severity::Warning => "./images/modal-default-warning-icon.svg",
|
||||||
|
Severity::Critical => "./images/modal-default-critical-icon.svg",
|
||||||
|
};
|
||||||
|
Some(rsx! {
|
||||||
|
div {
|
||||||
|
class: ClassName::MODAL_CONTENT_ICON_PLACEHOLDER,
|
||||||
|
img {
|
||||||
|
src: path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let button_class = match cx.props.severity {
|
||||||
|
Severity::Ok => SuccessButton,
|
||||||
|
Severity::Warning => WarningButton,
|
||||||
|
Severity::Critical => ErrorButton,
|
||||||
|
};
|
||||||
|
|
||||||
|
figure.as_ref()?;
|
||||||
|
|
||||||
|
cx.render(rsx! {
|
||||||
|
style { STYLE_SHEET },
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::MODAL,
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::MODAL_CONTENT,
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::MODAL_CONTENT_ICON,
|
||||||
|
{figure}
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::MODAL_CONTENT_TITLE,
|
||||||
|
cx.props.title,
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::MODAL_CONTENT_MSG,
|
||||||
|
|
||||||
|
&cx.props.children,
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::MODAL_CONTENT_BUTTONS,
|
||||||
|
button_class {
|
||||||
|
onclick: move |evt| {
|
||||||
|
if let Some(cb) = &cx.props.on_confirm {
|
||||||
|
cb.call(evt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
107
src/components/modal.scss
Normal file
107
src/components/modal.scss
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
@import "../_base.scss"
|
||||||
|
|
||||||
|
$modal-min-height: 15vh;
|
||||||
|
$modal-max-height: 55vh;
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
top: -100%;
|
||||||
|
margin-bottom: -100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
$content-width: 70vw;
|
||||||
|
$title-height: 3vh;
|
||||||
|
$space-height: 1vh;
|
||||||
|
$buttons-height: 5vh;
|
||||||
|
$icon-width: 10vw;
|
||||||
|
$msg-max-width: 50vw;
|
||||||
|
|
||||||
|
$msg-min-height: calc($modal-min-height - $title-height - $space-height - $buttons-height);
|
||||||
|
$msg-max-height: calc($modal-max-height - $title-height - $space-height - $buttons-height);
|
||||||
|
|
||||||
|
min-height: $modal-min-height;
|
||||||
|
max-height: $modal-max-height;
|
||||||
|
|
||||||
|
width: min-content;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: $icon-width 1vw minmax(max-content, $msg-max-width);
|
||||||
|
grid-template-rows: $title-height $space-height max-content $space-height $buttons-height;
|
||||||
|
grid-template-areas:
|
||||||
|
"icon . title"
|
||||||
|
"icon . ."
|
||||||
|
"icon . msg"
|
||||||
|
". . ."
|
||||||
|
"buttons buttons buttons"
|
||||||
|
;
|
||||||
|
|
||||||
|
padding: 2.5%;
|
||||||
|
|
||||||
|
background: get-color(greyscale, 0);
|
||||||
|
|
||||||
|
border: $border-normal;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
border-color: get-color(greyscale, 90);
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
grid-area: icon;
|
||||||
|
|
||||||
|
aspect-ratio: 1;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
border: $border-normal;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
|
||||||
|
&__placeholder {
|
||||||
|
width: calc(100% + (2 * $border-normal-width));
|
||||||
|
height: calc(100% - (2 * $border-normal-width));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
grid-area: title;
|
||||||
|
|
||||||
|
width: max-content;
|
||||||
|
|
||||||
|
font-size: calc($title-height - 0.5vh);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__msg {
|
||||||
|
grid-area: msg;
|
||||||
|
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
|
min-height: $msg-min-height;
|
||||||
|
max-height: $msg-max-height;
|
||||||
|
max-width: $msg-max-width;
|
||||||
|
|
||||||
|
overflow: scroll;
|
||||||
|
|
||||||
|
font-size: 1.5vh;
|
||||||
|
color: get-color(greyscale, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__buttons {
|
||||||
|
grid-area: buttons;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -23,14 +23,21 @@ impl IconShape for _Spinner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[derive(PartialEq, Props)]
|
||||||
pub fn Spinner(cx: Scope) -> Element {
|
pub struct SpinnerProps {
|
||||||
|
#[props(default = true)]
|
||||||
|
animate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn Spinner(cx: Scope<SpinnerProps>) -> Element {
|
||||||
cx.render(rsx! {
|
cx.render(rsx! {
|
||||||
style { STYLE_SHEET },
|
style { STYLE_SHEET },
|
||||||
|
|
||||||
div {
|
div {
|
||||||
class: ClassName::ROOT,
|
class: ClassName::SPINNER,
|
||||||
|
|
||||||
Icon {
|
Icon {
|
||||||
|
class: if cx.props.animate { "" } else { ClassName::PAUSED },
|
||||||
icon: _Spinner,
|
icon: _Spinner,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,78 +1,14 @@
|
|||||||
@import "../_base.scss"
|
@import "../_base.scss"
|
||||||
|
|
||||||
.root {
|
$background-height: 128px;
|
||||||
$background-height: 128px;
|
$background-width: 384px;
|
||||||
$background-width: 384px;
|
$logo-height: calc(32px * 2);
|
||||||
$logo-height: calc(32px * 2);
|
$logo-width: calc(64px * 2);
|
||||||
$logo-width: calc(64px * 2);
|
$logo-aspect-ratio: calc($logo-width / $logo-height);
|
||||||
|
|
||||||
height: $logo-height;
|
.spinner {
|
||||||
width: $logo-width;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
|
|
||||||
$logo-center-pos: calc(50% + ($background-height / 2) - ($logo-height / 2));
|
|
||||||
|
|
||||||
@media (0px < height <= calc($background-height * 5)) {
|
|
||||||
top: $logo-center-pos;
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 5) < height <= calc($background-height * 6)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 1));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 6) < height <= calc($background-height * 8)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 2));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 8) < height <= calc($background-height * 10)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 3));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 10) < height <= calc($background-height * 12)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 4));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 12) < height <= calc($background-height * 14)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 5));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 14) < height <= calc($background-height * 16)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 6));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 16) < height <= calc($background-height * 18)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 7));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 18) < height <= calc($background-height * 20)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 8));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 20) < height <= calc($background-height * 22)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 9));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 22) < height <= calc($background-height * 24)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 10));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 24) < height <= calc($background-height * 26)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 11));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 26) < height <= calc($background-height * 28)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 12));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 28) < height <= calc($background-height * 30)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 13));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 30) < height <= calc($background-height * 32)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 14));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 32) < height <= calc($background-height * 34)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 15));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 34) < height <= calc($background-height * 36)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 16));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 36) < height <= calc($background-height * 38)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 17));
|
|
||||||
}
|
|
||||||
@media (calc($background-height * 38) < height <= calc($background-height * 40)) {
|
|
||||||
top: calc($logo-center-pos + ($background-height * 18));
|
|
||||||
}
|
|
||||||
|
|
||||||
background-color: $greyscale-0;
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
$fps: 4;
|
$fps: 4;
|
||||||
@@ -82,22 +18,27 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
fill: $color-primary-100;
|
fill: get-color(primary, 100);
|
||||||
stroke: $greyscale-90;
|
stroke: get-color(greyscale, 90);
|
||||||
|
|
||||||
animation: 3s multicolor linear infinite;
|
animation: 3s multicolor linear infinite;
|
||||||
animation-timing-function: steps($steps, end);
|
animation-timing-function: steps($steps, end);
|
||||||
|
|
||||||
@keyframes multicolor {
|
@keyframes multicolor {
|
||||||
0% {
|
0% {
|
||||||
fill: $color-primary-100;
|
fill: get-color(primary, 100);
|
||||||
}
|
}
|
||||||
33% {
|
33% {
|
||||||
fill: $color-secondary-100;
|
fill: get-color(secondary, 100);
|
||||||
}
|
}
|
||||||
66% {
|
66% {
|
||||||
fill: $color-ternary-100;
|
fill: get-color(ternary, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.paused {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
223
src/components/text_input.rs
Normal file
223
src/components/text_input.rs
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::io_icons::IoEye;
|
||||||
|
use dioxus_free_icons::icons::io_icons::IoEyeOff;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
|
use super::icons::Pyramid;
|
||||||
|
|
||||||
|
turf::style_sheet!("src/components/text_input.scss");
|
||||||
|
|
||||||
|
pub trait InputPropsData {}
|
||||||
|
|
||||||
|
#[derive(Props)]
|
||||||
|
pub struct InputProps<'a, D: InputPropsData + 'a> {
|
||||||
|
value: Option<&'a str>,
|
||||||
|
placeholder: Option<&'a str>,
|
||||||
|
oninput: Option<EventHandler<'a, Event<FormData>>>,
|
||||||
|
state: Option<&'a UseRef<D>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
pub struct TextInputState {
|
||||||
|
pub is_valid: bool,
|
||||||
|
pub helper_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextInputState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
TextInputState {
|
||||||
|
is_valid: true,
|
||||||
|
helper_text: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.is_valid = true;
|
||||||
|
self.helper_text = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invalidate(&mut self, helper_text: String) {
|
||||||
|
self.is_valid = false;
|
||||||
|
self.helper_text = Some(helper_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TextInputState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputPropsData for TextInputState {}
|
||||||
|
|
||||||
|
pub fn TextInput<'a>(cx: Scope<'a, InputProps<'a, TextInputState>>) -> Element<'a> {
|
||||||
|
let mut criticity_class = "";
|
||||||
|
let mut helper_text = "".to_string();
|
||||||
|
|
||||||
|
if let Some(state) = cx.props.state {
|
||||||
|
let state = state.read();
|
||||||
|
if !state.is_valid {
|
||||||
|
criticity_class = ClassName::INVALID;
|
||||||
|
}
|
||||||
|
if let Some(text) = &state.helper_text {
|
||||||
|
helper_text = text.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let input_classes_str = [ClassName::TEXT_INPUT_INPUT, criticity_class].join(" ");
|
||||||
|
|
||||||
|
cx.render(rsx! {
|
||||||
|
style { STYLE_SHEET },
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::TEXT_INPUT,
|
||||||
|
|
||||||
|
input {
|
||||||
|
class: "{input_classes_str}",
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: cx.props.placeholder,
|
||||||
|
value: cx.props.value,
|
||||||
|
|
||||||
|
oninput: move |evt| {
|
||||||
|
if let Some(cb) = &cx.props.oninput {
|
||||||
|
cb.call(evt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::TEXT_INPUT_HELPER_TEXT,
|
||||||
|
|
||||||
|
p {
|
||||||
|
class: criticity_class,
|
||||||
|
helper_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Props)]
|
||||||
|
pub struct PasswordInputState {
|
||||||
|
text_input_state: TextInputState,
|
||||||
|
#[props(default = 0.0)]
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasswordInputState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
PasswordInputState {
|
||||||
|
text_input_state: TextInputState::new(),
|
||||||
|
score: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.text_input_state.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invalidate(&mut self, helper_text: String) {
|
||||||
|
self.text_input_state.invalidate(helper_text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PasswordInputState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputPropsData for PasswordInputState {}
|
||||||
|
|
||||||
|
pub fn PasswordTextInput<'a>(cx: Scope<'a, InputProps<'a, PasswordInputState>>) -> Element<'a> {
|
||||||
|
let mut criticity_class = "";
|
||||||
|
let mut helper_text: String = "".to_string();
|
||||||
|
let mut score: Option<f64> = None;
|
||||||
|
|
||||||
|
let show_password = use_state(cx, || false);
|
||||||
|
|
||||||
|
if let Some(state) = cx.props.state {
|
||||||
|
let state = state.read();
|
||||||
|
if !state.text_input_state.is_valid {
|
||||||
|
criticity_class = ClassName::INVALID;
|
||||||
|
}
|
||||||
|
if let Some(text) = &state.text_input_state.helper_text {
|
||||||
|
helper_text = text.to_string();
|
||||||
|
}
|
||||||
|
score = Some(state.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text_input_classes = [
|
||||||
|
ClassName::PASSWORD_TEXT_INPUT,
|
||||||
|
if score.is_none() {
|
||||||
|
ClassName::NO_STRENGTH
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.join(" ");
|
||||||
|
let input_classes = [ClassName::PASSWORD_TEXT_INPUT_INPUT, criticity_class].join(" ");
|
||||||
|
|
||||||
|
cx.render(rsx! {
|
||||||
|
style { STYLE_SHEET },
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: "{text_input_classes}",
|
||||||
|
|
||||||
|
input {
|
||||||
|
class: "{input_classes}",
|
||||||
|
r#type: if **show_password { "text" } else { "password" },
|
||||||
|
placeholder: cx.props.placeholder,
|
||||||
|
value: cx.props.value,
|
||||||
|
|
||||||
|
oninput: move |evt| {
|
||||||
|
if let Some(cb) = &cx.props.oninput {
|
||||||
|
cb.call(evt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
if let Some(score) = score {
|
||||||
|
rsx!(
|
||||||
|
div {
|
||||||
|
class: ClassName::PASSWORD_TEXT_INPUT_STRENGTH_LEVEL,
|
||||||
|
Pyramid {
|
||||||
|
ratio: score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::PASSWORD_TEXT_INPUT_SHOW_TOGGLE,
|
||||||
|
onclick: move |_| {
|
||||||
|
show_password.set(!**show_password);
|
||||||
|
},
|
||||||
|
|
||||||
|
if **show_password {
|
||||||
|
rsx!(
|
||||||
|
Icon {
|
||||||
|
icon: IoEyeOff,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
rsx!(
|
||||||
|
Icon {
|
||||||
|
icon: IoEye,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::PASSWORD_TEXT_INPUT_HELPER_TEXT,
|
||||||
|
|
||||||
|
p {
|
||||||
|
class: criticity_class,
|
||||||
|
helper_text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
130
src/components/text_input.scss
Normal file
130
src/components/text_input.scss
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
@import "../_base.scss"
|
||||||
|
|
||||||
|
%base-text-input {
|
||||||
|
$horizontal-padding: 1vw;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
width: calc(100% - (2 * $horizontal-padding));
|
||||||
|
|
||||||
|
border: $border-normal;
|
||||||
|
border-color: get-color(primary, 90);
|
||||||
|
border-radius: $border-radius;
|
||||||
|
|
||||||
|
padding-left: $horizontal-padding;
|
||||||
|
padding-right: $horizontal-padding;
|
||||||
|
padding-top: 0px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
%base-input {
|
||||||
|
$horizontal-padding: 1vw;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
font-size: 2vh;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.invalid {
|
||||||
|
border-color: get-color(critical, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
%base-helper-text {
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 0.3vh;
|
||||||
|
|
||||||
|
font-size: 1.2vh;
|
||||||
|
|
||||||
|
color: get-color(primary, 90);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&.invalid {
|
||||||
|
color: get-color(critical, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
@extend %base-text-input;
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
@extend %base-input;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__helper-text {
|
||||||
|
@extend %base-helper-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-text-input {
|
||||||
|
@extend %base-text-input;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 7.5% 1% 7.5%;
|
||||||
|
grid-template-rows: 100%;
|
||||||
|
grid-template-areas:
|
||||||
|
"input strength . toggle"
|
||||||
|
"helper helper helper helper"
|
||||||
|
;
|
||||||
|
|
||||||
|
transition: $transition-duration;
|
||||||
|
|
||||||
|
&.no-strength {
|
||||||
|
grid-template-columns: auto 0% 0% 7.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
@extend %base-input;
|
||||||
|
|
||||||
|
grid-area: input;
|
||||||
|
}
|
||||||
|
|
||||||
|
%inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__strength-level {
|
||||||
|
@extend %inner;
|
||||||
|
|
||||||
|
grid-area: strength;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
font-size: 2vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__show-toggle {
|
||||||
|
@extend %inner;
|
||||||
|
|
||||||
|
grid-area: toggle;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
color: get-color(secondary, 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__helper-text {
|
||||||
|
@extend %base-helper-text;
|
||||||
|
|
||||||
|
grid-area: helper;
|
||||||
|
}
|
||||||
|
}
|
@@ -7,7 +7,11 @@ pub fn Wallpaper(cx: Scope) -> Element {
|
|||||||
cx.render(rsx! {
|
cx.render(rsx! {
|
||||||
style { STYLE_SHEET },
|
style { STYLE_SHEET },
|
||||||
div {
|
div {
|
||||||
class: ClassName::ROOT,
|
class: ClassName::WALLPAPER,
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: ClassName::WALLPAPER_CONTENT,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,21 @@
|
|||||||
@import "../_base.scss"
|
@import "../_base.scss"
|
||||||
|
|
||||||
.root {
|
.wallpaper {
|
||||||
background-image: url("./images/background.svg");
|
height: 100%;
|
||||||
background-position: center;
|
width: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
|
||||||
width: 150%;
|
display: flex;
|
||||||
height: 150%;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
background-image: url("./images/wallpaper-pattern.svg");
|
||||||
|
background-position: center;
|
||||||
|
|
||||||
|
width: 150%;
|
||||||
|
height: 150%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
BIN
src/fonts/Geist/Geist-Black.woff2
Normal file
BIN
src/fonts/Geist/Geist-Black.woff2
Normal file
Binary file not shown.
BIN
src/fonts/Geist/Geist-Bold.woff2
Normal file
BIN
src/fonts/Geist/Geist-Bold.woff2
Normal file
Binary file not shown.
BIN
src/fonts/Geist/Geist-Light.woff2
Normal file
BIN
src/fonts/Geist/Geist-Light.woff2
Normal file
Binary file not shown.
BIN
src/fonts/Geist/Geist-Medium.woff2
Normal file
BIN
src/fonts/Geist/Geist-Medium.woff2
Normal file
Binary file not shown.
BIN
src/fonts/Geist/Geist-Regular.woff2
Normal file
BIN
src/fonts/Geist/Geist-Regular.woff2
Normal file
Binary file not shown.
BIN
src/fonts/Geist/Geist-SemiBold.woff2
Normal file
BIN
src/fonts/Geist/Geist-SemiBold.woff2
Normal file
Binary file not shown.
BIN
src/fonts/Geist/Geist-Thin.woff2
Normal file
BIN
src/fonts/Geist/Geist-Thin.woff2
Normal file
Binary file not shown.
BIN
src/fonts/Geist/Geist-UltraBlack.woff2
Normal file
BIN
src/fonts/Geist/Geist-UltraBlack.woff2
Normal file
Binary file not shown.
BIN
src/fonts/Geist/Geist-UltraLight.woff2
Normal file
BIN
src/fonts/Geist/Geist-UltraLight.woff2
Normal file
Binary file not shown.
BIN
src/fonts/Geist/GeistVariableVF.ttf
Normal file
BIN
src/fonts/Geist/GeistVariableVF.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Geist/GeistVariableVF.woff2
Normal file
BIN
src/fonts/Geist/GeistVariableVF.woff2
Normal file
Binary file not shown.
92
src/fonts/Geist/LICENSE.TXT
Normal file
92
src/fonts/Geist/LICENSE.TXT
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
Geist Sans and Geist Mono Font
|
||||||
|
(C) 2023 Vercel, made in collaboration with basement.studio
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is available with a FAQ at: http://scripts.sil.org/OFL and copied below
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
41
src/main.rs
41
src/main.rs
@@ -7,10 +7,13 @@ pub mod utils;
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus_desktop::Config;
|
use dioxus_desktop::Config;
|
||||||
use fermi::*;
|
use fermi::*;
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
use tracing::{debug, Level};
|
use tracing::{debug, Level};
|
||||||
|
|
||||||
use crate::base::{login, sync_rooms, APP_SETTINGS, CHATS_WIN_INTERFACE, ROOMS, SESSION};
|
use crate::base::{login, sync_rooms, APP_SETTINGS, CHATS_WIN_INTERFACE, ROOMS, SESSION};
|
||||||
use crate::components::chats_window::{ChatsWindow, ChatsWindowProps};
|
use crate::components::chats_window::{ChatsWindow, ChatsWindowProps};
|
||||||
|
use crate::components::loading::LoadingPage;
|
||||||
|
use crate::components::login::Login;
|
||||||
use crate::components::main_window::MainWindow;
|
use crate::components::main_window::MainWindow;
|
||||||
|
|
||||||
mod base;
|
mod base;
|
||||||
@@ -25,6 +28,20 @@ fn App(cx: Scope) -> Element {
|
|||||||
let rooms_ref = use_atom_ref(cx, &ROOMS);
|
let rooms_ref = use_atom_ref(cx, &ROOMS);
|
||||||
let chats_win_interface_ref = use_atom_ref(cx, &CHATS_WIN_INTERFACE);
|
let chats_win_interface_ref = use_atom_ref(cx, &CHATS_WIN_INTERFACE);
|
||||||
|
|
||||||
|
let ready = use_state(cx, || false);
|
||||||
|
|
||||||
|
// Dummy timer simulating the loading of the application
|
||||||
|
let _: &Coroutine<()> = use_coroutine(cx, |_: UnboundedReceiver<_>| {
|
||||||
|
to_owned![ready];
|
||||||
|
async move {
|
||||||
|
debug!("Not ready");
|
||||||
|
sleep(Duration::from_secs(3)).await;
|
||||||
|
// sleep(Duration::from_secs(0)).await;
|
||||||
|
debug!("Ready");
|
||||||
|
ready.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let chats_win_state = use_state(cx, || None);
|
let chats_win_state = use_state(cx, || None);
|
||||||
|
|
||||||
let login_coro = use_coroutine(cx, |rx| {
|
let login_coro = use_coroutine(cx, |rx| {
|
||||||
@@ -88,13 +105,25 @@ fn App(cx: Scope) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.render(rsx! {
|
if **ready {
|
||||||
MainWindow {}
|
if session_ref.read().is_logged {
|
||||||
})
|
debug!("Should render the MainWindow component");
|
||||||
|
cx.render(rsx! {
|
||||||
|
MainWindow {},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
cx.render(rsx! {
|
||||||
|
Login {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cx.render(rsx! {
|
||||||
|
LoadingPage {},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
fn main() {
|
||||||
async fn main() -> anyhow::Result<()> {
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
// .pretty()
|
// .pretty()
|
||||||
.with_max_level(Level::DEBUG)
|
.with_max_level(Level::DEBUG)
|
||||||
@@ -102,6 +131,4 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
dioxus_desktop::launch(App);
|
dioxus_desktop::launch(App);
|
||||||
// dioxus_web::launch(App);
|
// dioxus_web::launch(App);
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user