23 Commits

Author SHA1 Message Date
aad0064a0c Merge branch 'redesign-login-form' into develop 2024-03-30 18:31:15 +01:00
83fe388e8d 🚨 Fix clippy warnings 2024-03-30 18:24:04 +01:00
448b81b65d Add Login component 2024-03-30 17:40:17 +01:00
4e963ce063 🎨 Factorize the definition of the Button components 2024-03-30 14:37:44 +01:00
cf9737fc76 Add Modal component 2024-03-30 13:46:53 +01:00
5c91df206c 💄 Store colors in a nested map to make them reachable using criteria 2024-03-30 08:23:52 +01:00
0ab6aaac1c Add PasswordTextInput component
The TextInput component has been reworked to factorize some pieces of code with PasswordTextInput.
2024-03-21 21:12:49 +01:00
89b1f10b6e Add Pyramid icon 2024-03-21 21:05:04 +01:00
570a969cee 💄 Fix conflicts regarding the generated CSS class names 2024-03-21 18:32:40 +01:00
ceeda1a771 Redesign Login component and add fields validation 2024-03-15 14:58:58 +01:00
fc0d3b1212 ♻️ Replace constcat with const_format 2024-03-15 12:33:25 +01:00
01f589e789 Add helper_text to TextInput (previously TextEdit) 2024-03-15 12:24:42 +01:00
c746fb6552 💄 Rework Login component 2024-03-10 12:02:18 +01:00
1073a592ed 🔨 Make the Design System color tokens reachable from the rust code
The design system tokens are specified in the _base.scss file. To avoid to duplicate their value in a rust file, a new
step has been added to the building process to generate the `style_vars.rs` file which make the color tokens reachable
to the app.
2024-03-10 11:40:00 +01:00
dd0754073c Add TextField component 2024-03-10 11:35:25 +01:00
b05e3efce4 🐛 Add SVG pattern file used to render the Wallpaper 2024-03-10 11:01:33 +01:00
0a4969e079 💄 Center and uppercase the content of the Register and Login buttons 2024-03-10 10:42:02 +01:00
f52733d9a6 💄 Add buttons which will be used by the Login component 2024-03-09 22:46:00 +01:00
bb56d24f02 💄 Add border variables to the base SCSS file 2024-03-09 22:44:04 +01:00
043a721429 Make Spinner animation suspendable 2024-03-09 13:04:01 +01:00
46c251ef90 ♻️ Make Spinner reusable (not only by loading view) 2024-03-03 23:35:09 +01:00
257b36eae1 ✏️ Fix typo in color names 2024-03-03 23:31:00 +01:00
ff430edffe 💄 Use Geist font everywhere. 2024-03-03 23:29:49 +01:00
43 changed files with 2495 additions and 260 deletions

View File

@@ -27,7 +27,17 @@ log = "0.4.20"
tracing = "0.1.40"
futures-util = "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]
target = "x86_64-unknown-linux-gnu"
[build-dependencies]
regex = "1.10.3"
[package.metadata.turf.class_names]
template = "<original_name>--<id>"

View File

@@ -9,7 +9,7 @@ The goal of this project is to propose a new open-source implementation of the f
## Back-end
This project is based on the [Matrix.org](https://matrix.org/) building blocks (back-end and front-end SDK) to avoid to
reinvent the wheel. This solution provides:
reinvent the wheel. This solution provides:
- [Open-source protocol](https://spec.matrix.org/v1.9/).
- Features expected for a messaging solution in 2024 (multi-devices management, emojis, integrations, redaction,

View File

@@ -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() {
// Tell Cargo to rerun this build script if any SCSS file
// in the 'src' directory or its subdirectories changes.
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);
};
}

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View 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

View File

@@ -1,6 +1,9 @@
@use "sass:map";
$font-size: 100vh * 0.01;
$icon-size: $font-size * 2;
// TODO: To remove once the design updated.
$border-style: thin solid #BED6E0;
$greyscale-90: #1B1B1B;
@@ -26,29 +29,168 @@ $color-primary-70: #005A75;
$color-primary-60: #004059;
$color-primary-50: #002740;
$color-secondary_150: #EAE5F3;
$color-secondary_140: #D5CCE7;
$color-secondary_130: #BFB3DB;
$color-secondary_120: #AA9BCF;
$color-secondary_110: #9583C3;
$color-secondary_100: #7E6BB6;
$color-secondary_90: #6957A0;
$color-secondary_80: #53448A;
$color-secondary_70: #3E3174;
$color-secondary_60: #281F5F;
$color-secondary_50: #110E4B;
$color-secondary-150: #EAE5F3;
$color-secondary-140: #D5CCE7;
$color-secondary-130: #BFB3DB;
$color-secondary-120: #AA9BCF;
$color-secondary-110: #9583C3;
$color-secondary-100: #7E6BB6;
$color-secondary-90: #6957A0;
$color-secondary-80: #53448A;
$color-secondary-70: #3E3174;
$color-secondary-60: #281F5F;
$color-secondary-50: #110E4B;
$color-ternary_150: #FCE0E9;
$color-ternary_140: #F7C1D4;
$color-ternary_130: #F1A1BF;
$color-ternary_120: #E981AA;
$color-ternary_110: #E05F96;
$color-ternary_100: #D53583;
$color-ternary_90: #BC106D;
$color-ternary_80: #A30059;
$color-ternary_70: #8B0046;
$color-ternary_60: #720033;
$color-ternary_50: #5A0022;
$color-ternary-150: #FCE0E9;
$color-ternary-140: #F7C1D4;
$color-ternary-130: #F1A1BF;
$color-ternary-120: #E981AA;
$color-ternary-110: #E05F96;
$color-ternary-100: #D53583;
$color-ternary-90: #BC106D;
$color-ternary-80: #A30059;
$color-ternary-70: #8B0046;
$color-ternary-60: #720033;
$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 {
height: 100vh;
@@ -56,14 +198,21 @@ body {
margin: 0px;
padding: 0px;
outline: 0px;
font-family: Tahoma, sans-serif;
}
#main {
height: 100%;
width: 100%;
font-family: "Geist";
}
::selection {
background-color: transparent;
}
// TODO: To remove once the design updated.
.aeroButton {
height: 50%;
min-height: 16px;

View File

@@ -7,7 +7,7 @@ pub fn AvatarSelector(cx: Scope) -> Element {
style { STYLE_SHEET },
div {
class: ClassName::SELECTOR,
class: ClassName::AVATAR_SELECTOR,
svg {
view_box: "0 0 100 100",
linearGradient {
@@ -46,7 +46,7 @@ pub fn AvatarSelector(cx: Scope) -> Element {
},
},
img {
class: ClassName::PICTURE,
class: ClassName::AVATAR_SELECTOR_PICTURE,
src: "./images/default-avatar.png",
},
},

View File

@@ -1,9 +1,9 @@
.selector {
.avatar-selector {
position: relative;
height: 100%;
aspect-ratio: 1;
.picture {
&__picture {
$height: 65%;
$margin: calc(100% - $height) / 2;

120
src/components/button.rs Normal file
View 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);

View 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);
}

View File

@@ -30,3 +30,9 @@ impl Interface {
self.sender.send(Tasks::ToggleRoom(room_id))
}
}
impl Default for Interface {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,9 +1,13 @@
use dioxus::prelude::*;
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");
include!(concat!(env!("OUT_DIR"), "/style_vars.rs"));
use style::{COLOR_PRIMARY_100, COLOR_TERNARY_100};
pub fn DownArrowIcon(cx: Scope) -> Element {
cx.render(rsx! {
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 },
}
})
}

View File

@@ -5,3 +5,8 @@
fill: white;
}
}
.pyramid-icon {
height: 100%;
width: 100%;
}

View File

@@ -1,8 +1,8 @@
use dioxus::prelude::*;
use tracing::debug;
use crate::components::spinner::Spinner;
use crate::components::wallpaper::Wallpaper;
use super::spinner::Spinner;
use super::wallpaper::Wallpaper;
turf::style_sheet!("src/components/loading.scss");
@@ -14,11 +14,14 @@ pub fn LoadingPage(cx: Scope) -> Element {
style { STYLE_SHEET },
div {
class: ClassName::ROOT,
class: ClassName::LOADING,
Wallpaper {},
Spinner {},
div {
class: ClassName::LOADING_SPINNER,
Spinner {},
}
}
})
}

View File

@@ -1,6 +1,7 @@
@import "../_base.scss"
@import "./spinner.scss"
.root {
.loading {
height: 100%;
width: 100%;
@@ -8,6 +9,72 @@
align-items: 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);
}
}

View File

@@ -1,136 +1,890 @@
use std::str::FromStr;
use std::borrow::Cow;
use std::collections::HashMap;
use const_format::formatcp;
use dioxus::prelude::*;
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::components::avatar_selector::AvatarSelector;
use crate::components::header::Header;
use crate::base::{Session, SESSION};
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");
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");
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 {
ClassName::INVALID_INPUT
} else {
""
let data = data_ref.read();
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 |_| {
cx.spawn({
if **spinner_animated && session.read().is_logged {
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 login_ref = login.read();
let modal_configs = use_ref(cx, Vec::<PasswordSuggestionsModalConfig>::new);
let modal_config = use_state(cx, || None::<PasswordSuggestionsModalConfig>);
session.write().update(
login_ref.homeserver_url.clone(),
login_ref.email.clone(),
login_ref.password.clone(),
);
if modal_configs.read().len() > 0 && modal_config.is_none() {
modal_config.set(modal_configs.write_silent().pop());
}
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 placeholder = EMPTY_PLACEHOLDER.to_string();
let homeserver_url_value = login_ref.homeserver_url.as_ref().unwrap_or(&placeholder);
let email_value = login_ref.email.as_ref().unwrap_or(&placeholder);
let password_value = login_ref.password.as_ref().unwrap_or(&placeholder);
let on_clicked_register = {
to_owned![handlers, modal_configs];
move |_| {
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! {
style { STYLE_SHEET },
Wallpaper {},
div {
class: ClassName::ROOT,
class: ClassName::LOGIN,
div {
class: ClassName::HEADER,
Header {},
},
class: "{form_classes_str}",
div {
class: ClassName::BODY,
div {
class: ClassName::AVATAR_SELECTOR_CONTAINER,
AvatarSelector {},
class: ClassName::LOGIN_FORM_PHOTO,
onclick: move |_| {
random_avatar_future.restart()
},
{avatar},
},
p {
"Matrix homeserver:"
},
input {
id: "input-homeserver-url",
r#type: "text",
name: "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 {
class: ClassName::LOGIN_FORM_HOMESERVER,
TextInput {
placeholder: "Homeserver URL",
value: "{homeserver_url}",
state: homeserver_url_state,
oninput: on_input![data_ref, homeserver_url],
},
},
div {
class: ClassName::FOOTER_BUTTONS,
input {
class: ClassName::BUTTON,
onclick: run_matrix_client,
r#type: "submit",
value: "sign in",
class: ClassName::LOGIN_FORM_ID,
TextInput {
placeholder: "{id_placeholder}",
value: "{id}",
state: id_state,
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,
}
}
}

View File

@@ -1,53 +1,135 @@
@import "../_base.scss";
@import "../_base.scss"
@import "./spinner.scss"
.root {
width: 90%;
height: 98%;
.login {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 5%;
padding-top: 2%;
position: relative;
top: -100vh;
background: linear-gradient(rgb(138, 191, 209), rgb(236, 246, 249) 10%);
margin-bottom: -100vh;
.header {
height: 5%;
width: 100%;
}
&__form {
$height: 95%;
height: $height;
.body {
height: 50%;
width: 50%;
max-width: 400px;
max-height: $form-max-height;
aspect-ratio: $form-aspect-ratio;
display: flex;
flex-direction: column;
justify-content: center;
border: $border-big;
border-color: get-color(primary, 90);
border-radius: $border-radius;
padding-bottom: 3%;
background-color: get-color(greyscale, 0);
.invalidInput {
border-color: red;
}
display: grid;
.avatar-selector-container {
height: 30%;
width: 100%;
$padding-col: 5%;
$button-height: 8%;
$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 {
width: 100%;
transition: $transition-duration;
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;
justify-content: center;
}
}
&__photo {
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;
}
}
}

View File

@@ -4,7 +4,6 @@ use tracing::debug;
use crate::base::SESSION;
use crate::components::contacts_window::ContactsWindow;
use crate::components::login::Login;
pub fn MainWindow(cx: Scope) -> Element {
debug!("MainWindow rendering");
@@ -16,8 +15,5 @@ pub fn MainWindow(cx: Scope) -> Element {
if is_logged {
rsx!(ContactsWindow {})
}
else {
rsx!(Login {})
}
})
}

View File

@@ -1,4 +1,5 @@
pub mod avatar_selector;
pub mod button;
pub mod chats_window;
pub mod contacts_window;
pub mod header;
@@ -6,5 +7,7 @@ pub mod icons;
pub mod loading;
pub mod login;
pub mod main_window;
pub mod modal;
pub mod spinner;
pub mod text_input;
pub mod wallpaper;

229
src/components/modal.rs Normal file
View 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
View 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;
}
}
}

View File

@@ -23,14 +23,21 @@ impl IconShape for _Spinner {
}
}
#[component]
pub fn Spinner(cx: Scope) -> Element {
#[derive(PartialEq, Props)]
pub struct SpinnerProps {
#[props(default = true)]
animate: bool,
}
pub fn Spinner(cx: Scope<SpinnerProps>) -> Element {
cx.render(rsx! {
style { STYLE_SHEET },
div {
class: ClassName::ROOT,
class: ClassName::SPINNER,
Icon {
class: if cx.props.animate { "" } else { ClassName::PAUSED },
icon: _Spinner,
}
}

View File

@@ -1,78 +1,14 @@
@import "../_base.scss"
.root {
$background-height: 128px;
$background-width: 384px;
$logo-height: calc(32px * 2);
$logo-width: calc(64px * 2);
$background-height: 128px;
$background-width: 384px;
$logo-height: calc(32px * 2);
$logo-width: calc(64px * 2);
$logo-aspect-ratio: calc($logo-width / $logo-height);
height: $logo-height;
width: $logo-width;
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;
.spinner {
height: 100%;
width: 100%;
svg {
$fps: 4;
@@ -82,22 +18,27 @@
height: 100%;
width: 100%;
fill: $color-primary-100;
stroke: $greyscale-90;
fill: get-color(primary, 100);
stroke: get-color(greyscale, 90);
animation: 3s multicolor linear infinite;
animation-timing-function: steps($steps, end);
@keyframes multicolor {
0% {
fill: $color-primary-100;
fill: get-color(primary, 100);
}
33% {
fill: $color-secondary-100;
fill: get-color(secondary, 100);
}
66% {
fill: $color-ternary-100;
fill: get-color(ternary, 100);
}
}
&.paused {
animation-play-state: paused;
}
}
}

View 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
}
},
}
})
}

View 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;
}
}

View File

@@ -7,7 +7,11 @@ pub fn Wallpaper(cx: Scope) -> Element {
cx.render(rsx! {
style { STYLE_SHEET },
div {
class: ClassName::ROOT,
class: ClassName::WALLPAPER,
div {
class: ClassName::WALLPAPER_CONTENT,
}
}
})
}

View File

@@ -1,9 +1,21 @@
@import "../_base.scss"
.root {
background-image: url("./images/background.svg");
background-position: center;
.wallpaper {
height: 100%;
width: 100%;
z-index: -1;
width: 150%;
height: 150%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
&__content {
background-image: url("./images/wallpaper-pattern.svg");
background-position: center;
width: 150%;
height: 150%;
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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.

View File

@@ -7,10 +7,13 @@ pub mod utils;
use dioxus::prelude::*;
use dioxus_desktop::Config;
use fermi::*;
use tokio::time::{sleep, Duration};
use tracing::{debug, Level};
use crate::base::{login, sync_rooms, APP_SETTINGS, CHATS_WIN_INTERFACE, ROOMS, SESSION};
use crate::components::chats_window::{ChatsWindow, ChatsWindowProps};
use crate::components::loading::LoadingPage;
use crate::components::login::Login;
use crate::components::main_window::MainWindow;
mod base;
@@ -25,6 +28,20 @@ fn App(cx: Scope) -> Element {
let rooms_ref = use_atom_ref(cx, &ROOMS);
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 login_coro = use_coroutine(cx, |rx| {
@@ -88,13 +105,25 @@ fn App(cx: Scope) -> Element {
}
}
cx.render(rsx! {
MainWindow {}
})
if **ready {
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]
async fn main() -> anyhow::Result<()> {
fn main() {
tracing_subscriber::fmt()
// .pretty()
.with_max_level(Level::DEBUG)
@@ -102,6 +131,4 @@ async fn main() -> anyhow::Result<()> {
dioxus_desktop::launch(App);
// dioxus_web::launch(App);
Ok(())
}