diff --git a/Cargo.toml b/Cargo.toml index f548ef5..d3e0b06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "--" diff --git a/README.md b/README.md index 320d6be..954dfd9 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/build.rs b/build.rs index 30524a9..90cf41a 100644 --- a/build.rs +++ b/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() { // 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

(filename: P) -> io::Result>> +where + P: AsRef, +{ + 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); + }; } diff --git a/images/login-profile-placeholder.svg b/images/login-profile-placeholder.svg new file mode 100644 index 0000000..935196f --- /dev/null +++ b/images/login-profile-placeholder.svg @@ -0,0 +1 @@ +"Shapes" by "Florian Körner", licensed under "CC0 1.0". / Remix of the original. - Created with dicebear.comShapesFlorian Körnerhttps://www.dicebear.com \ No newline at end of file diff --git a/images/modal-default-critical-icon.svg b/images/modal-default-critical-icon.svg new file mode 100644 index 0000000..27c0b81 --- /dev/null +++ b/images/modal-default-critical-icon.svg @@ -0,0 +1 @@ +"Notionists" by "Zoish", licensed under "CC0 1.0". / Remix of the original. - Created with dicebear.comNotionistsZoishhttps://heyzoish.gumroad.com/l/notionists \ No newline at end of file diff --git a/images/modal-default-ok-icon.svg b/images/modal-default-ok-icon.svg new file mode 100644 index 0000000..bf525e9 --- /dev/null +++ b/images/modal-default-ok-icon.svg @@ -0,0 +1 @@ +"Notionists" by "Zoish", licensed under "CC0 1.0". / Remix of the original. - Created with dicebear.comNotionistsZoishhttps://heyzoish.gumroad.com/l/notionists \ No newline at end of file diff --git a/images/modal-default-warning-icon.svg b/images/modal-default-warning-icon.svg new file mode 100644 index 0000000..e312db4 --- /dev/null +++ b/images/modal-default-warning-icon.svg @@ -0,0 +1 @@ +"Notionists" by "Zoish", licensed under "CC0 1.0". / Remix of the original. - Created with dicebear.comNotionistsZoishhttps://heyzoish.gumroad.com/l/notionists \ No newline at end of file diff --git a/images/wallpaper-pattern.svg b/images/wallpaper-pattern.svg new file mode 100644 index 0000000..861dbb5 --- /dev/null +++ b/images/wallpaper-pattern.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/_base.scss b/src/_base.scss index b386845..110d339 100644 --- a/src/_base.scss +++ b/src/_base.scss @@ -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; diff --git a/src/components/avatar_selector.rs b/src/components/avatar_selector.rs index e14be4b..3e47f10 100644 --- a/src/components/avatar_selector.rs +++ b/src/components/avatar_selector.rs @@ -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", }, }, diff --git a/src/components/avatar_selector.scss b/src/components/avatar_selector.scss index db6e999..44d843b 100644 --- a/src/components/avatar_selector.scss +++ b/src/components/avatar_selector.scss @@ -1,9 +1,9 @@ -.selector { +.avatar-selector { position: relative; height: 100%; aspect-ratio: 1; - .picture { + &__picture { $height: 65%; $margin: calc(100% - $height) / 2; diff --git a/src/components/button.rs b/src/components/button.rs new file mode 100644 index 0000000..847735f --- /dev/null +++ b/src/components/button.rs @@ -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>, + 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>, +} + +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); diff --git a/src/components/button.scss b/src/components/button.scss new file mode 100644 index 0000000..4476bf9 --- /dev/null +++ b/src/components/button.scss @@ -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); +} diff --git a/src/components/chats_window/interface.rs b/src/components/chats_window/interface.rs index b797a04..0a43fd3 100644 --- a/src/components/chats_window/interface.rs +++ b/src/components/chats_window/interface.rs @@ -30,3 +30,9 @@ impl Interface { self.sender.send(Tasks::ToggleRoom(room_id)) } } + +impl Default for Interface { + fn default() -> Self { + Self::new() + } +} diff --git a/src/components/icons.rs b/src/components/icons.rs index 52f6a0d..dca1134 100644 --- a/src/components/icons.rs +++ b/src/components/icons.rs @@ -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 }, + } + }) +} diff --git a/src/components/icons.scss b/src/components/icons.scss index 19e1d0c..494370c 100644 --- a/src/components/icons.scss +++ b/src/components/icons.scss @@ -5,3 +5,8 @@ fill: white; } } + +.pyramid-icon { + height: 100%; + width: 100%; +} diff --git a/src/components/loading.rs b/src/components/loading.rs index 1cd6c2d..c1a5f14 100644 --- a/src/components/loading.rs +++ b/src/components/loading.rs @@ -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 {}, + } } }) } diff --git a/src/components/loading.scss b/src/components/loading.scss index f542f5b..f26b2a1 100644 --- a/src/components/loading.scss +++ b/src/components/loading.scss @@ -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); + } } diff --git a/src/components/login.rs b/src/components/login.rs index a1a5b4b..6f4e49c 100644 --- a/src/components/login.rs +++ b/src/components/login.rs @@ -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 { + 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 = 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; +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.box_clone() + } +} + +#[derive(Clone)] +struct TextInputHandler { + state_ref: UseRef, +} + +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 { + Box::new(self.clone()) + } +} + +#[derive(Clone)] +struct UrlInputHandler { + state_ref: UseRef, +} + +impl UrlInputHandler { + pub fn new(state_ref: UseRef) -> 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 { + Box::new(self.clone()) + } +} + +#[derive(Clone)] +struct EmailInputHandler { + state_ref: UseRef, +} + +impl EmailInputHandler { + pub fn new(state_ref: UseRef) -> 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 { + Box::new(self.clone()) + } +} + +#[derive(Clone)] +struct PasswordInputHandler { + state_ref: UseRef, +} + +impl PasswordInputHandler { + pub fn new(state_ref: UseRef) -> 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 { + Box::new(self.clone()) + } +} + +fn on_validation_errors( + field_errors: &HashMap<&str, &Vec>, + 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::>(); + + 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::>(); + 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, + #[validate(required, custom(function = validate_id, use_context))] + id: Option, + #[validate(required, custom(function = validate_password, use_context))] + password: Option, + confirm_password: Option, +} + +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, 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, +} +impl PasswordValidationResult { + pub fn new() -> Self { + PasswordValidationResult { + score: 0, + rating: 0.0, + suggestions: Vec::::new(), + } + } +} + +fn compute_password_score( + password: &str, + with_suggestions: Option, +) -> Option { + 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, 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, + data_ref: &UseRef, +) -> 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, + data_ref: &UseRef, +) -> 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>, +} +impl<'a> InputHandlers<'a> { + fn new() -> Self { + Self { + handlers: HashMap::new(), + } + } + fn insert(&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) + 'b, +) -> LazyNodes<'a, 'b> +where + 'b: 'a, +{ + let suggestions = config.suggestions.get(PASSWORD_FIELD_NAME); + + let mut rendered_suggestions = Vec::::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>; + +#[derive(Clone)] +struct PasswordSuggestionsModalConfig<'a> { + severity: Severity, + title: String, + suggestions: Suggestions<'a>, +} +impl<'a> PasswordSuggestionsModalConfig<'a> { + fn new(severity: Option, 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::::new); + let modal_config = use_state(cx, || None::); - 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::::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| { + 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, - email: Option, - password: Option, -} - -impl Login { - fn new() -> Self { - Self { - homeserver_url: None, - email: None, - password: None, - } - } -} diff --git a/src/components/login.scss b/src/components/login.scss index bbb2496..1ceed48 100644 --- a/src/components/login.scss +++ b/src/components/login.scss @@ -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; + } + } } diff --git a/src/components/main_window.rs b/src/components/main_window.rs index d1055dd..bc0e673 100644 --- a/src/components/main_window.rs +++ b/src/components/main_window.rs @@ -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 {}) - } }) } diff --git a/src/components/mod.rs b/src/components/mod.rs index 2a4230e..a429ed3 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -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; diff --git a/src/components/modal.rs b/src/components/modal.rs new file mode 100644 index 0000000..a440fb1 --- /dev/null +++ b/src/components/modal.rs @@ -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, + eyes: Vec, + lips: Vec, +} + +#[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>, +} + +fn dicebear_variants() -> &'static HashMap> { + static HASHMAP: OnceLock> = 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::>() + .join(",") +} + +async fn generate_random_figure(url: &String, severity: Severity) -> Option { + let mut res: Option = 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); + } + }, + }, + }, + }, + }, + }) +} diff --git a/src/components/modal.scss b/src/components/modal.scss new file mode 100644 index 0000000..5c3bc9f --- /dev/null +++ b/src/components/modal.scss @@ -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; + } + } +} diff --git a/src/components/spinner.rs b/src/components/spinner.rs index cb4a773..d453081 100644 --- a/src/components/spinner.rs +++ b/src/components/spinner.rs @@ -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) -> 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, } } diff --git a/src/components/spinner.scss b/src/components/spinner.scss index 655c01f..8a25818 100644 --- a/src/components/spinner.scss +++ b/src/components/spinner.scss @@ -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; + } + } } diff --git a/src/components/text_input.rs b/src/components/text_input.rs new file mode 100644 index 0000000..56d770e --- /dev/null +++ b/src/components/text_input.rs @@ -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>>, + state: Option<&'a UseRef>, +} + +#[derive(PartialEq)] +pub struct TextInputState { + pub is_valid: bool, + pub helper_text: Option, +} + +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 = 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 + } + }, + } + }) +} diff --git a/src/components/text_input.scss b/src/components/text_input.scss new file mode 100644 index 0000000..2b86eb6 --- /dev/null +++ b/src/components/text_input.scss @@ -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; + } +} diff --git a/src/components/wallpaper.rs b/src/components/wallpaper.rs index 1630b8a..00abdd9 100644 --- a/src/components/wallpaper.rs +++ b/src/components/wallpaper.rs @@ -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, + } } }) } diff --git a/src/components/wallpaper.scss b/src/components/wallpaper.scss index cb3f70e..fa45c0b 100644 --- a/src/components/wallpaper.scss +++ b/src/components/wallpaper.scss @@ -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%; + } } diff --git a/src/fonts/Geist/Geist-Black.woff2 b/src/fonts/Geist/Geist-Black.woff2 new file mode 100644 index 0000000..c8230cb Binary files /dev/null and b/src/fonts/Geist/Geist-Black.woff2 differ diff --git a/src/fonts/Geist/Geist-Bold.woff2 b/src/fonts/Geist/Geist-Bold.woff2 new file mode 100644 index 0000000..d0562e4 Binary files /dev/null and b/src/fonts/Geist/Geist-Bold.woff2 differ diff --git a/src/fonts/Geist/Geist-Light.woff2 b/src/fonts/Geist/Geist-Light.woff2 new file mode 100644 index 0000000..5661120 Binary files /dev/null and b/src/fonts/Geist/Geist-Light.woff2 differ diff --git a/src/fonts/Geist/Geist-Medium.woff2 b/src/fonts/Geist/Geist-Medium.woff2 new file mode 100644 index 0000000..76edd10 Binary files /dev/null and b/src/fonts/Geist/Geist-Medium.woff2 differ diff --git a/src/fonts/Geist/Geist-Regular.woff2 b/src/fonts/Geist/Geist-Regular.woff2 new file mode 100644 index 0000000..2ebd009 Binary files /dev/null and b/src/fonts/Geist/Geist-Regular.woff2 differ diff --git a/src/fonts/Geist/Geist-SemiBold.woff2 b/src/fonts/Geist/Geist-SemiBold.woff2 new file mode 100644 index 0000000..a6c53e8 Binary files /dev/null and b/src/fonts/Geist/Geist-SemiBold.woff2 differ diff --git a/src/fonts/Geist/Geist-Thin.woff2 b/src/fonts/Geist/Geist-Thin.woff2 new file mode 100644 index 0000000..a330c07 Binary files /dev/null and b/src/fonts/Geist/Geist-Thin.woff2 differ diff --git a/src/fonts/Geist/Geist-UltraBlack.woff2 b/src/fonts/Geist/Geist-UltraBlack.woff2 new file mode 100644 index 0000000..198caea Binary files /dev/null and b/src/fonts/Geist/Geist-UltraBlack.woff2 differ diff --git a/src/fonts/Geist/Geist-UltraLight.woff2 b/src/fonts/Geist/Geist-UltraLight.woff2 new file mode 100644 index 0000000..5416852 Binary files /dev/null and b/src/fonts/Geist/Geist-UltraLight.woff2 differ diff --git a/src/fonts/Geist/GeistVariableVF.ttf b/src/fonts/Geist/GeistVariableVF.ttf new file mode 100644 index 0000000..3bf8230 Binary files /dev/null and b/src/fonts/Geist/GeistVariableVF.ttf differ diff --git a/src/fonts/Geist/GeistVariableVF.woff2 b/src/fonts/Geist/GeistVariableVF.woff2 new file mode 100644 index 0000000..328a020 Binary files /dev/null and b/src/fonts/Geist/GeistVariableVF.woff2 differ diff --git a/src/fonts/Geist/LICENSE.TXT b/src/fonts/Geist/LICENSE.TXT new file mode 100644 index 0000000..df71062 --- /dev/null +++ b/src/fonts/Geist/LICENSE.TXT @@ -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. diff --git a/src/main.rs b/src/main.rs index f6149db..7465a14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(()) }