🎨 Isolate infra and ui components
The src/base.rs is still to be reworked.
This commit is contained in:
246
src/ui/_base.scss
Normal file
246
src/ui/_base.scss
Normal file
@@ -0,0 +1,246 @@
|
||||
@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;
|
||||
$greyscale-80: #303030;
|
||||
$greyscale-70: #474747;
|
||||
$greyscale-60: #5E5E5E;
|
||||
$greyscale-50: #777777;
|
||||
$greyscale-40: #919191;
|
||||
$greyscale-30: #ABABAB;
|
||||
$greyscale-20: #C6C6C6;
|
||||
$greyscale-10: #E2E2E2;
|
||||
$greyscale-0: #FFFFFF;
|
||||
|
||||
$color-primary-150: #E2F2F7;
|
||||
$color-primary-140: #C4E5EE;
|
||||
$color-primary-130: #A5D7E6;
|
||||
$color-primary-120: #83CADE;
|
||||
$color-primary-110: #5CBDD5;
|
||||
$color-primary-100: #1DB2CF;
|
||||
$color-primary-90: #0092AF;
|
||||
$color-primary-80: #007691;
|
||||
$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-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;
|
||||
width: 100vw;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
outline: 0px;
|
||||
}
|
||||
|
||||
#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;
|
||||
aspect-ratio: 1;
|
||||
background-color: transparent;
|
||||
border: 2px solid transparent;
|
||||
background-size: contain !important;
|
||||
margin-right: 1%;
|
||||
}
|
||||
.aeroButton:hover {
|
||||
border-image: url(./images/aerobutton_border.png) 2 round;
|
||||
}
|
||||
.aeroButton:active {
|
||||
border-image: url(./images/aerobutton_border_down.png) 2 round;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 50%;
|
||||
min-height: 16px;
|
||||
aspect-ratio: 1;
|
||||
background-color: transparent;
|
||||
border: 2px solid transparent;
|
||||
background-size: contain !important;
|
||||
margin-right: 1%;
|
||||
}
|
||||
.button:hover {
|
||||
border-image: url(./images/button_border.png) 2 round;
|
||||
}
|
||||
.button:active {
|
||||
border-image: url(./images/button_border_down.png) 2 round;
|
||||
}
|
54
src/ui/components/avatar_selector.rs
Normal file
54
src/ui/components/avatar_selector.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
turf::style_sheet!("src/ui/components/avatar_selector.scss");
|
||||
|
||||
pub fn AvatarSelector() -> Element {
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::AVATAR_SELECTOR,
|
||||
svg {
|
||||
view_box: "0 0 100 100",
|
||||
linearGradient {
|
||||
id: "avatar-gradient",
|
||||
x1: 1,
|
||||
y1: 1,
|
||||
x2: 0,
|
||||
y2: 0,
|
||||
stop {
|
||||
offset: "0%",
|
||||
stop_color: "rgb(138, 191, 209)",
|
||||
}
|
||||
stop {
|
||||
offset: "60%",
|
||||
stop_color: "rgb(236, 246, 249)",
|
||||
}
|
||||
},
|
||||
filter {
|
||||
id: "avatar-shadow",
|
||||
feDropShadow {
|
||||
dx: 2,
|
||||
dy: 2,
|
||||
std_deviation: 3,
|
||||
flood_opacity: 0.5,
|
||||
},
|
||||
},
|
||||
rect {
|
||||
x: "10",
|
||||
y: "10",
|
||||
width: "80",
|
||||
height: "80",
|
||||
rx: "12",
|
||||
fill: "url('#avatar-gradient')",
|
||||
filter: "url('#avatar-shadow')",
|
||||
stroke: "grey",
|
||||
},
|
||||
},
|
||||
img {
|
||||
class: ClassName::AVATAR_SELECTOR_PICTURE,
|
||||
src: "./images/default-avatar.png",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
17
src/ui/components/avatar_selector.scss
Normal file
17
src/ui/components/avatar_selector.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
.avatar-selector {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
|
||||
&__picture {
|
||||
$height: 65%;
|
||||
$margin: calc(100% - $height) / 2;
|
||||
|
||||
position: absolute;
|
||||
height: $height;
|
||||
aspect-ratio: 1;
|
||||
|
||||
top: $margin;
|
||||
right: $margin;
|
||||
}
|
||||
}
|
117
src/ui/components/button.rs
Normal file
117
src/ui/components/button.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::{Icon, IconShape};
|
||||
|
||||
turf::style_sheet!("src/ui/components/button.scss");
|
||||
|
||||
#[derive(PartialEq, Clone, Props)]
|
||||
struct _ButtonProps {
|
||||
children: Element,
|
||||
#[props(default = false)]
|
||||
focus: bool,
|
||||
id: Option<String>,
|
||||
onclick: Option<EventHandler<MouseEvent>>,
|
||||
style: String,
|
||||
}
|
||||
|
||||
macro_rules! svg_text_icon {
|
||||
($name:ident,$text:literal) => {
|
||||
#[derive(Copy, Clone, PartialEq)]
|
||||
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) -> Element {
|
||||
rsx! {
|
||||
text {
|
||||
x: "50%",
|
||||
y: "50%",
|
||||
$text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! svg_text_button {
|
||||
($name:ident,$style:ident,$icon:ident) => {
|
||||
pub fn $name(props: ButtonProps) -> Element {
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
Button {
|
||||
id: props.id,
|
||||
|
||||
style: {ClassName::$style},
|
||||
|
||||
onclick: move |event| {
|
||||
if let Some(cb) = &props.onclick {
|
||||
cb.call(event);
|
||||
}
|
||||
},
|
||||
|
||||
focus: props.focus,
|
||||
|
||||
Icon {
|
||||
icon: $icon,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Props)]
|
||||
pub struct ButtonProps {
|
||||
#[props(default = false)]
|
||||
focus: bool,
|
||||
id: Option<String>,
|
||||
onclick: Option<EventHandler<MouseEvent>>,
|
||||
style: Option<String>,
|
||||
children: Element,
|
||||
}
|
||||
|
||||
fn Button(props: ButtonProps) -> Element {
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
button {
|
||||
id: props.id,
|
||||
|
||||
class: props.style,
|
||||
|
||||
onmounted: move |evt| async move {
|
||||
_ = evt.set_focus(props.focus).await;
|
||||
},
|
||||
|
||||
onclick: move |evt| {
|
||||
if let Some(cb) = &props.onclick {
|
||||
cb.call(evt);
|
||||
}
|
||||
},
|
||||
|
||||
{props.children},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
svg_text_icon!(RegisterText, "REGISTER");
|
||||
svg_text_button!(RegisterButton, REGISTER_BUTTON, RegisterText);
|
||||
|
||||
svg_text_icon!(LoginText, "LOGIN");
|
||||
svg_text_button!(LoginButton, LOGIN_BUTTON, LoginText);
|
||||
|
||||
svg_text_icon!(SuccessText, "OK");
|
||||
svg_text_button!(SuccessButton, SUCCESS_BUTTON, SuccessText);
|
||||
|
||||
svg_text_icon!(WarningText, "WARNING");
|
||||
svg_text_button!(WarningButton, WARNING_BUTTON, WarningText);
|
||||
|
||||
svg_text_icon!(ErrorText, "ERROR");
|
||||
svg_text_button!(ErrorButton, ERROR_BUTTON, ErrorText);
|
60
src/ui/components/button.scss
Normal file
60
src/ui/components/button.scss
Normal file
@@ -0,0 +1,60 @@
|
||||
@import "../_base.scss"
|
||||
|
||||
%button {
|
||||
height: 100%;
|
||||
aspect-ratio: 3.5;
|
||||
|
||||
border: $border-normal;
|
||||
border-radius: $border-radius;
|
||||
|
||||
color: get-color(greyscale, 0);
|
||||
|
||||
font-family: "Geist";
|
||||
font-weight: bold;
|
||||
|
||||
svg {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
text {
|
||||
font-size: 50;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
fill: get-color(greyscale, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin button($color-name, $color-level) {
|
||||
@extend %button;
|
||||
|
||||
background-color: get_color($color-name, $color-level);
|
||||
|
||||
&:hover {
|
||||
background-color: get_color($color-name, calc($color-level - 10));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: get_color($color-name, calc($color-level - 20));
|
||||
}
|
||||
}
|
||||
|
||||
.register-button {
|
||||
@include button(ternary, 90);
|
||||
}
|
||||
|
||||
.login-button {
|
||||
@include button(secondary, 90);
|
||||
}
|
||||
|
||||
.success-button {
|
||||
@include button(success, 100);
|
||||
}
|
||||
|
||||
.warning-button {
|
||||
@include button(warning, 100);
|
||||
}
|
||||
|
||||
.error-button {
|
||||
@include button(critical, 100);
|
||||
}
|
94
src/ui/components/chats_window/chats_window.scss
Normal file
94
src/ui/components/chats_window/chats_window.scss
Normal file
@@ -0,0 +1,94 @@
|
||||
@import "../../_base.scss"
|
||||
|
||||
.chats-window {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
$horizontal-padding-margin: calc((2*100%)/1980);
|
||||
|
||||
.tabs {
|
||||
height: 2%;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
overflow-x: scroll;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
padding: 0 $horizontal-padding-margin;
|
||||
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
$clamped-horizontal-padding-margin: clamp(5px, $horizontal-padding-margin, $horizontal-padding-margin);
|
||||
margin: 0 $clamped-horizontal-padding-margin;
|
||||
padding: 0 $clamped-horizontal-padding-margin;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
background-color: #EFF9F9;
|
||||
border: $border-style;
|
||||
|
||||
$radius: calc((6*100%)/1980);
|
||||
$clamped-radius: clamp(6px, $radius, $radius);
|
||||
border-radius: $clamped-radius $clamped-radius 0 0;
|
||||
|
||||
font-size: $font-size;
|
||||
|
||||
img {
|
||||
height: $icon-size;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat {
|
||||
height: 98%;
|
||||
width: 100%;
|
||||
|
||||
background-color: #ECF6F9;
|
||||
|
||||
.header {
|
||||
height: 7%;
|
||||
|
||||
border: $border-style;
|
||||
|
||||
.info {
|
||||
height: 45%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding-left: 2%;
|
||||
|
||||
background: linear-gradient(180deg, #BFE3EB, #DEFBFE);
|
||||
|
||||
font-size: $font-size;
|
||||
|
||||
.room-name {
|
||||
margin: 0;
|
||||
margin-top: 1%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.room-topic {
|
||||
margin: 0;
|
||||
color: darkgrey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
87
src/ui/components/chats_window/conversation.rs
Normal file
87
src/ui/components/chats_window/conversation.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use dioxus::prelude::*;
|
||||
use matrix_sdk::ruma::OwnedRoomId;
|
||||
use tracing::error;
|
||||
|
||||
use super::edit_section::EditSection;
|
||||
use crate::base::{sync_messages, ROOMS};
|
||||
use crate::ui::components::avatar_selector::AvatarSelector;
|
||||
use crate::ui::components::icons::DownArrowIcon;
|
||||
|
||||
turf::style_sheet!("src/ui/components/chats_window/conversation.scss");
|
||||
|
||||
#[component]
|
||||
pub(super) fn Conversation(room_id: OwnedRoomId) -> Element {
|
||||
error!("Conversation {} rendering", room_id);
|
||||
|
||||
let _sync_message_coro: Coroutine<()> =
|
||||
use_coroutine(|_: UnboundedReceiver<_>| sync_messages(&ROOMS, room_id));
|
||||
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::CONVERSATION,
|
||||
div {
|
||||
class: ClassName::ROOM_EVENTS,
|
||||
ul {
|
||||
li {
|
||||
class: ClassName::ROOM_EVENT,
|
||||
div {
|
||||
p {
|
||||
class: ClassName::TITLE,
|
||||
"MON POTE says:"
|
||||
},
|
||||
p {
|
||||
class: ClassName::CONTENT,
|
||||
"Coucou mon pote",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
div {
|
||||
class: ClassName::OTHER_AVATAR_SELECTOR_CONTAINER,
|
||||
div {
|
||||
class: ClassName::AVATAR_SELECTOR,
|
||||
AvatarSelector {},
|
||||
},
|
||||
div {
|
||||
class: ClassName::WEBCAM,
|
||||
img {
|
||||
src: "images/webcam.svg"
|
||||
},
|
||||
},
|
||||
div {
|
||||
class: ClassName::ARROW_ICON,
|
||||
DownArrowIcon {}
|
||||
},
|
||||
},
|
||||
div {
|
||||
class: ClassName::HOLDER,
|
||||
"••••••"
|
||||
},
|
||||
div {
|
||||
class: ClassName::EDIT_SECTION,
|
||||
EditSection {},
|
||||
},
|
||||
div {
|
||||
class: ClassName::MY_AVATAR_SELECTOR_CONTAINER,
|
||||
div {
|
||||
class: ClassName::AVATAR_SELECTOR,
|
||||
AvatarSelector {},
|
||||
},
|
||||
div {
|
||||
class: ClassName::WEBCAM,
|
||||
img {
|
||||
src: "images/webcam.svg"
|
||||
},
|
||||
},
|
||||
div {
|
||||
class: ClassName::ARROW_ICON,
|
||||
DownArrowIcon {}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
113
src/ui/components/chats_window/conversation.scss
Normal file
113
src/ui/components/chats_window/conversation.scss
Normal file
@@ -0,0 +1,113 @@
|
||||
@import "../../_base.scss"
|
||||
|
||||
.conversation {
|
||||
$padding-top: 2%;
|
||||
|
||||
height: calc(93% - $padding-top);
|
||||
|
||||
padding-left: 2%;
|
||||
padding-top: $padding-top;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 75% 25%;
|
||||
grid-template-rows: 70% 1% 29%;
|
||||
cursor: pointer;
|
||||
|
||||
.holder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
|
||||
color: darkgrey;
|
||||
}
|
||||
|
||||
.room-events {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: flex-start;
|
||||
|
||||
border: $border-style;
|
||||
background-color: #FFFFFF;
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.room-event {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: space-between;
|
||||
|
||||
padding-top: 1%;
|
||||
|
||||
font-size: $font-size;
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 0;
|
||||
padding-left: 2%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
%selector-container {
|
||||
aspect-ratio: 1;
|
||||
|
||||
grid-column: 2;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 10% 15% 50% 15% 10%;
|
||||
grid-template-rows: 80% 20%;
|
||||
|
||||
.avatar-selector {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 6;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.webcam {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
grid-column: 4;
|
||||
grid-row: 2;
|
||||
|
||||
svg {
|
||||
path:last-child {
|
||||
fill: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.other-avatar-selector-container {
|
||||
@extend %selector-container;
|
||||
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.my-avatar-selector-container {
|
||||
@extend %selector-container;
|
||||
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.edit-section {
|
||||
grid-row: 3;
|
||||
}
|
||||
}
|
52
src/ui/components/chats_window/edit_section.rs
Normal file
52
src/ui/components/chats_window/edit_section.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
turf::style_sheet!("src/ui/components/chats_window/edit_section.scss");
|
||||
|
||||
pub fn EditSection() -> Element {
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::INPUT_AREA,
|
||||
|
||||
div {
|
||||
class: ClassName::BUTTONS,
|
||||
|
||||
button {
|
||||
"😀"
|
||||
},
|
||||
|
||||
button {
|
||||
"😉"
|
||||
},
|
||||
|
||||
button {
|
||||
"😴"
|
||||
},
|
||||
|
||||
button {
|
||||
"🔊"
|
||||
},
|
||||
},
|
||||
|
||||
textarea {
|
||||
class: ClassName::EDIT,
|
||||
placeholder: "Type your message here...",
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::CMD_BUTTONS,
|
||||
|
||||
button {
|
||||
class: ClassName::SEND_BUTTON,
|
||||
|
||||
"Send"
|
||||
},
|
||||
|
||||
button {
|
||||
"🔎"
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
55
src/ui/components/chats_window/edit_section.scss
Normal file
55
src/ui/components/chats_window/edit_section.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
@import "../../_base.scss"
|
||||
|
||||
.input-area {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
margin-bottom: 2%;
|
||||
|
||||
.buttons {
|
||||
$padding-top-bottom: 0.5%;
|
||||
|
||||
height: calc(10% - ($padding-top-bottom * 2));
|
||||
padding-left: 2%;
|
||||
padding-top: $padding-top-bottom;
|
||||
padding-bottom: $padding-top-bottom;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
border: $border-style;
|
||||
background: linear-gradient(180deg, #F5FDFF, #E3ECF0, #F5FDFF);
|
||||
|
||||
button {
|
||||
@extend .aeroButton;
|
||||
height: $icon-size;
|
||||
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-right: 2%;
|
||||
|
||||
font-size: larger;
|
||||
}
|
||||
}
|
||||
|
||||
.edit {
|
||||
height: 80%;
|
||||
// Remove border from width
|
||||
width: calc(100% - 2px);
|
||||
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cmd-buttons {
|
||||
height: 7%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
width: 15%;
|
||||
}
|
||||
}
|
39
src/ui/components/chats_window/interface.rs
Normal file
39
src/ui/components/chats_window/interface.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use matrix_sdk::ruma::OwnedRoomId;
|
||||
use tokio::sync::broadcast::error::SendError;
|
||||
use tokio::sync::broadcast::{channel, Receiver, Sender};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Tasks {
|
||||
ToggleRoom(OwnedRoomId),
|
||||
}
|
||||
|
||||
pub struct Interface {
|
||||
sender: Sender<Tasks>,
|
||||
receiver: RefCell<Receiver<Tasks>>,
|
||||
}
|
||||
|
||||
impl Interface {
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = channel::<Tasks>(32);
|
||||
Self {
|
||||
sender,
|
||||
receiver: RefCell::new(receiver),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn receiver(&self) -> &RefCell<Receiver<Tasks>> {
|
||||
&self.receiver
|
||||
}
|
||||
|
||||
pub fn toggle_room(&self, room_id: OwnedRoomId) -> Result<usize, SendError<Tasks>> {
|
||||
self.sender.send(Tasks::ToggleRoom(room_id))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Interface {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
164
src/ui/components/chats_window/mod.rs
Normal file
164
src/ui/components/chats_window/mod.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
mod conversation;
|
||||
mod edit_section;
|
||||
mod navbar;
|
||||
|
||||
pub(crate) mod interface;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use matrix_sdk::ruma::OwnedRoomId;
|
||||
use tokio::sync::broadcast::Receiver;
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::base::{sync_rooms, Room, ROOMS};
|
||||
use crate::infrastructure::messaging::matrix::requester::Receivers;
|
||||
use conversation::Conversation;
|
||||
use navbar::Navbar;
|
||||
|
||||
use interface::{Interface, Tasks};
|
||||
|
||||
turf::style_sheet!("src/ui/components/chats_window/chats_window.scss");
|
||||
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
pub struct ChatsWindowProps {
|
||||
pub receivers: Receivers,
|
||||
pub interface: Signal<Interface>,
|
||||
}
|
||||
|
||||
fn render_rooms_tabs(
|
||||
by_id_rooms: &GlobalSignal<HashMap<OwnedRoomId, RefCell<Room>>>,
|
||||
displayed_room_ids: Signal<HashSet<OwnedRoomId>>,
|
||||
) -> Vec<Element> {
|
||||
let rooms_ref = by_id_rooms.read();
|
||||
let displayed_room_ids = displayed_room_ids.read();
|
||||
rooms_ref
|
||||
.values()
|
||||
.filter(|room| displayed_room_ids.contains(&room.borrow().id()))
|
||||
.map(|room| {
|
||||
let room = room.borrow();
|
||||
let room_name = room.name().unwrap_or(room.id().to_string());
|
||||
rsx!(
|
||||
div {
|
||||
class: ClassName::TAB,
|
||||
button {
|
||||
img {
|
||||
src: "./images/status_online.png",
|
||||
},
|
||||
"{room_name}",
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_rooms_conversations(
|
||||
by_id_rooms: &GlobalSignal<HashMap<OwnedRoomId, RefCell<Room>>>,
|
||||
displayed_room_ids: Signal<HashSet<OwnedRoomId>>,
|
||||
) -> Vec<Element> {
|
||||
let rooms_ref = by_id_rooms.read();
|
||||
let displayed_room_ids = displayed_room_ids.read();
|
||||
rooms_ref
|
||||
.values()
|
||||
.filter(|room| displayed_room_ids.contains(&room.borrow().id()))
|
||||
.map(|room| {
|
||||
let room_id = room.borrow().id();
|
||||
rsx!(Conversation { room_id: room_id },)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn handle_controls(
|
||||
receiver_ref: &RefCell<Receiver<Tasks>>,
|
||||
mut displayed_room_ids: Signal<HashSet<OwnedRoomId>>,
|
||||
) {
|
||||
loop {
|
||||
let result = receiver_ref.borrow_mut().recv().await;
|
||||
match result {
|
||||
Ok(task) => match task {
|
||||
Tasks::ToggleRoom(room_id) => {
|
||||
error!("ON TOGGLE ROOM {}", room_id);
|
||||
let mut displayed_room_ids = displayed_room_ids.write();
|
||||
match displayed_room_ids.take(&room_id) {
|
||||
Some(_) => {
|
||||
error!("{} room already dispayed... close it", room_id);
|
||||
}
|
||||
None => {
|
||||
error!("{} room isn't dispayed... open it", room_id);
|
||||
displayed_room_ids.insert(room_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(err) => error!("{}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ChatsWindow(props: ChatsWindowProps) -> Element {
|
||||
debug!("ChatsWindow rendering");
|
||||
|
||||
let receivers = &props.receivers;
|
||||
let interface_ref = &props.interface;
|
||||
|
||||
let displayed_room_ids = use_signal(HashSet::<OwnedRoomId>::new);
|
||||
|
||||
let sync_rooms_coro = use_coroutine(|rx| {
|
||||
to_owned![receivers];
|
||||
sync_rooms(rx, receivers, &ROOMS)
|
||||
});
|
||||
sync_rooms_coro.send(true);
|
||||
|
||||
let _: Coroutine<()> = use_coroutine(|_: UnboundedReceiver<_>| {
|
||||
to_owned![interface_ref, displayed_room_ids];
|
||||
async move {
|
||||
let interface = interface_ref.read();
|
||||
let receiver = &interface.receiver();
|
||||
handle_controls(receiver, displayed_room_ids).await
|
||||
}
|
||||
});
|
||||
|
||||
let rendered_rooms_tabs = render_rooms_tabs(&ROOMS, displayed_room_ids);
|
||||
let rendered_rooms_conversations = render_rooms_conversations(&ROOMS, displayed_room_ids);
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::CHATS_WINDOW,
|
||||
|
||||
div {
|
||||
class: ClassName::TABS,
|
||||
{rendered_rooms_tabs.into_iter()},
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::CHAT,
|
||||
|
||||
div {
|
||||
class: ClassName::HEADER,
|
||||
|
||||
div {
|
||||
class: ClassName::INFO,
|
||||
|
||||
p {
|
||||
class: ClassName::ROOM_NAME,
|
||||
"MON POTE",
|
||||
},
|
||||
|
||||
p {
|
||||
class: ClassName::ROOM_TOPIC,
|
||||
"LE STATUT A MON POTE",
|
||||
},
|
||||
},
|
||||
|
||||
Navbar {},
|
||||
},
|
||||
|
||||
{rendered_rooms_conversations.into_iter()},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
50
src/ui/components/chats_window/navbar.rs
Normal file
50
src/ui/components/chats_window/navbar.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use dioxus::prelude::*;
|
||||
use tracing::debug;
|
||||
|
||||
turf::style_sheet!("src/ui/components/chats_window/navbar.scss");
|
||||
|
||||
pub fn Navbar() -> Element {
|
||||
debug!("Navbar rendering");
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::NAVBAR,
|
||||
|
||||
button {
|
||||
style: "background: url(./images/add_user2.png) center no-repeat",
|
||||
},
|
||||
|
||||
button {
|
||||
style: "background: url(./images/directory.png) center no-repeat",
|
||||
},
|
||||
|
||||
button {
|
||||
style: "background: url(./images/phone.png) center no-repeat",
|
||||
},
|
||||
|
||||
button {
|
||||
style: "background: url(./images/medias.png) center no-repeat",
|
||||
},
|
||||
|
||||
button {
|
||||
style: "background: url(./images/games.png) center no-repeat",
|
||||
},
|
||||
|
||||
button {
|
||||
style: "background: url(./images/ban_user.png) center no-repeat",
|
||||
},
|
||||
|
||||
button {
|
||||
class: ClassName::FLEX_RIGHT_AERO_BUTTON,
|
||||
style: "background: url(./images/brush.png) center no-repeat",
|
||||
},
|
||||
|
||||
button {
|
||||
class: ClassName::FLEX_LAST_BUTTON,
|
||||
style: "background: url(./images/settings.png) center no-repeat",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
26
src/ui/components/chats_window/navbar.scss
Normal file
26
src/ui/components/chats_window/navbar.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
@import "../../_base.scss"
|
||||
|
||||
.navbar {
|
||||
height: 55%;
|
||||
padding-left: 2%;
|
||||
padding-right: 2%;
|
||||
|
||||
background: linear-gradient(180deg, #A9D3E0, #F0F9FA);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
@extend .aeroButton;
|
||||
padding-right: 2%;
|
||||
}
|
||||
|
||||
.flex-right-aero-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.flex-last-button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
26
src/ui/components/contacts_window/contacts.rs
Normal file
26
src/ui/components/contacts_window/contacts.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::ui::components::contacts_window::contacts_section::{
|
||||
filter_people_conversations, filter_room_conversations, ContactsSection,
|
||||
};
|
||||
|
||||
turf::style_sheet!("src/ui/components/contacts_window/contacts.scss");
|
||||
|
||||
pub fn Contacts() -> Element {
|
||||
debug!("Contacts rendering");
|
||||
|
||||
// TODO: Test overflow
|
||||
// TODO: Add offline users ?
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::CONTACTS,
|
||||
ContactsSection {name: "Groups", filter: Rc::new(filter_room_conversations)},
|
||||
ContactsSection {name: "Available", filter: Rc::new(filter_people_conversations)},
|
||||
},
|
||||
}
|
||||
}
|
6
src/ui/components/contacts_window/contacts.scss
Normal file
6
src/ui/components/contacts_window/contacts.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
@import "../../_base.scss"
|
||||
|
||||
.contacts {
|
||||
height: 72%;
|
||||
background-color: white;
|
||||
}
|
155
src/ui/components/contacts_window/contacts_section.rs
Normal file
155
src/ui/components/contacts_window/contacts_section.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::io_icons::IoChevronDown;
|
||||
use dioxus_free_icons::Icon;
|
||||
use matrix_sdk::{ruma::OwnedRoomId, RoomState};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::base::{ByIdRooms, Room, CHATS_WIN_INTERFACE, ROOMS};
|
||||
use crate::ui::components::chats_window::interface::Interface as ChatsWindowInterface;
|
||||
|
||||
turf::style_sheet!("src/ui/components/contacts_window/contacts_section.scss");
|
||||
|
||||
fn ContactsArrow() -> Element {
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
Icon {
|
||||
icon: IoChevronDown,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
static NO_NAME_REPR: &str = "No name";
|
||||
static NO_SUBJECT_REPR: &str = "No subject";
|
||||
|
||||
pub(super) fn filter_people_conversations(
|
||||
by_id_rooms: &GlobalSignal<ByIdRooms>,
|
||||
) -> Vec<RefCell<Room>> {
|
||||
let by_id_rooms = by_id_rooms.read();
|
||||
|
||||
let mut filtered_rooms = Vec::<RefCell<Room>>::with_capacity(by_id_rooms.len());
|
||||
|
||||
for room in by_id_rooms.values() {
|
||||
let is_direct = room.borrow().is_direct.unwrap();
|
||||
if !is_direct {
|
||||
filtered_rooms.push(room.to_owned());
|
||||
}
|
||||
}
|
||||
filtered_rooms
|
||||
}
|
||||
|
||||
pub(super) fn filter_room_conversations(
|
||||
by_id_rooms: &GlobalSignal<ByIdRooms>,
|
||||
) -> Vec<RefCell<Room>> {
|
||||
let by_id_rooms = by_id_rooms.read();
|
||||
|
||||
let mut filtered_rooms = Vec::<RefCell<Room>>::with_capacity(by_id_rooms.len());
|
||||
|
||||
for room in by_id_rooms.values() {
|
||||
let is_direct = room.borrow().is_direct.unwrap();
|
||||
if is_direct {
|
||||
filtered_rooms.push(room.to_owned());
|
||||
}
|
||||
}
|
||||
filtered_rooms
|
||||
}
|
||||
|
||||
// TODO: Handle errors
|
||||
fn on_clicked_room(
|
||||
room_id: &OwnedRoomId,
|
||||
chats_window_interface: &GlobalSignal<ChatsWindowInterface>,
|
||||
) {
|
||||
let _ = chats_window_interface.read().toggle_room(room_id.clone());
|
||||
}
|
||||
|
||||
#[derive(Props, Clone)]
|
||||
pub struct ContactsSectionProps {
|
||||
name: String,
|
||||
filter: Rc<dyn Fn(&GlobalSignal<ByIdRooms>) -> Vec<RefCell<Room>>>,
|
||||
}
|
||||
impl PartialEq for ContactsSectionProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.name == other.name && Rc::ptr_eq(&self.filter, &other.filter)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ContactsSection(props: ContactsSectionProps) -> Element {
|
||||
debug!("ContactsSection rendering");
|
||||
|
||||
let contacts = props.filter.to_owned()(&ROOMS);
|
||||
let contacts_len = contacts.len();
|
||||
|
||||
let mut show = use_signal(|| false);
|
||||
|
||||
let classes = [
|
||||
ClassName::SECTION,
|
||||
if *show.read() { ClassName::ACTIVE } else { "" },
|
||||
]
|
||||
.join(" ");
|
||||
|
||||
let rendered_contacts = contacts.into_iter().map(|room_ref| {
|
||||
let room = room_ref.borrow();
|
||||
|
||||
let room_topic = room
|
||||
.topic
|
||||
.as_ref()
|
||||
.unwrap_or(&RefCell::new(NO_SUBJECT_REPR.to_string()))
|
||||
.borrow()
|
||||
.to_owned();
|
||||
let room_name = room.name().unwrap_or(NO_NAME_REPR.to_string());
|
||||
let room_id = room.id();
|
||||
|
||||
let is_invited = room.matrix_room.state() == RoomState::Invited;
|
||||
|
||||
let formatted = format!(
|
||||
"{room_name} - {}",
|
||||
if is_invited {
|
||||
"Invited - ".to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
rsx! {
|
||||
li {
|
||||
onclick: move |_| on_clicked_room(&room_id, &CHATS_WIN_INTERFACE),
|
||||
img {
|
||||
src: "./images/status_online.png",
|
||||
},
|
||||
p {
|
||||
{formatted},
|
||||
},
|
||||
p {
|
||||
style: "color: darkgrey;",
|
||||
{room_topic},
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: "{classes}",
|
||||
|
||||
p {
|
||||
class: ClassName::HEADER,
|
||||
onclick: move |_| {
|
||||
let state = *show.read();
|
||||
show.set(!state)
|
||||
},
|
||||
|
||||
ContactsArrow {},
|
||||
|
||||
{format!("{} ({contacts_len})", props.name)},
|
||||
},
|
||||
|
||||
ul {
|
||||
{rendered_contacts.into_iter()},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
67
src/ui/components/contacts_window/contacts_section.scss
Normal file
67
src/ui/components/contacts_window/contacts_section.scss
Normal file
@@ -0,0 +1,67 @@
|
||||
@import "../../_base.scss"
|
||||
|
||||
.section {
|
||||
width: 100%;
|
||||
font-size: $font-size;
|
||||
|
||||
&.active {
|
||||
ul {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 2%;
|
||||
width: 98%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
margin-left: 1%;
|
||||
padding-top: 1%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ul {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transition: 0.4s ease;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
img {
|
||||
height: $icon-size;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: 0.4s ease;
|
||||
}
|
||||
|
||||
.contact {
|
||||
list-style-type: none;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
cursor: pointer
|
||||
}
|
||||
}
|
84
src/ui/components/contacts_window/contacts_window.scss
Normal file
84
src/ui/components/contacts_window/contacts_window.scss
Normal file
@@ -0,0 +1,84 @@
|
||||
@import "../../_base.scss";
|
||||
|
||||
.contactsWindow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background-color: #ECF6F9;
|
||||
font-family: "Tahoma", sans-serif;
|
||||
|
||||
border: thin solid #707070;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 5px #00000050;
|
||||
|
||||
|
||||
.header {
|
||||
height: 10%;
|
||||
width: 100%;
|
||||
|
||||
.titleBar {
|
||||
height: 60%;
|
||||
width: 100%;
|
||||
background:
|
||||
linear-gradient(180deg, #7DC5E3, #3883A3);
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
height: 40%;
|
||||
width: 100%;
|
||||
background:
|
||||
linear-gradient(180deg, #00658B, #0077A6);
|
||||
}
|
||||
}
|
||||
|
||||
.contactsNav {
|
||||
height: calc(31/1080*100%);
|
||||
background:
|
||||
linear-gradient(180deg, #00658B, #0077A6);
|
||||
|
||||
.inner {
|
||||
margin-left: 1%;
|
||||
margin-right: 1%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.flexRightAeroButton {
|
||||
@extend .aeroButton;
|
||||
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
height: calc(38/1080*100%);
|
||||
width: 100%;
|
||||
|
||||
border-bottom: thin solid #e2eaf3;
|
||||
|
||||
.inner {
|
||||
height: 100%;
|
||||
width: 98%;
|
||||
padding-left: 1%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.searchInput {
|
||||
height: calc(23/38*100%);
|
||||
width: 100%;
|
||||
margin-right: 1%;
|
||||
border: thin solid #c7c7c7;
|
||||
box-shadow: inset 0 0 calc(3/1080*100%) #0000002a;
|
||||
font-size: 8pt;
|
||||
|
||||
padding-left: 1%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
height: 10%;
|
||||
}
|
||||
}
|
98
src/ui/components/contacts_window/mod.rs
Normal file
98
src/ui/components/contacts_window/mod.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
mod contacts;
|
||||
mod contacts_section;
|
||||
mod user_infos;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::ui::components::contacts_window::contacts::Contacts;
|
||||
use crate::ui::components::contacts_window::user_infos::UserInfos;
|
||||
|
||||
turf::style_sheet!("src/ui/components/contacts_window/contacts_window.scss");
|
||||
|
||||
pub fn ContactsWindow() -> Element {
|
||||
debug!("ContactsWindow rendering");
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::CONTACTS_WINDOW,
|
||||
|
||||
div {
|
||||
class: ClassName::HEADER,
|
||||
|
||||
div {
|
||||
class: ClassName::TITLE_BAR,
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::USER_INFO,
|
||||
},
|
||||
|
||||
UserInfos {},
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::CONTACTS_NAV,
|
||||
div {
|
||||
class: ClassName::INNER,
|
||||
|
||||
button {
|
||||
class: ClassName::AERO_BUTTON,
|
||||
style: "background: url(./images/letter.png) center no-repeat",
|
||||
},
|
||||
button {
|
||||
class: ClassName::AERO_BUTTON,
|
||||
style: "background: url(./images/directory.png) no-repeat center",
|
||||
},
|
||||
button {
|
||||
class: ClassName::AERO_BUTTON,
|
||||
style: "background: url(./images/news.png) no-repeat center",
|
||||
},
|
||||
|
||||
button {
|
||||
class: ClassName::FLEX_RIGHT_AERO_BUTTON,
|
||||
style: "background: url(./images/brush.png) no-repeat center",
|
||||
},
|
||||
button {
|
||||
class: ClassName::AERO_BUTTON,
|
||||
style: "background: url(./images/settings.png) no-repeat center",
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::SEARCH,
|
||||
|
||||
div {
|
||||
class: ClassName::INNER,
|
||||
|
||||
input {
|
||||
class: ClassName::SEARCH_INPUT,
|
||||
placeholder: "Find a contact...",
|
||||
r#type: "text",
|
||||
},
|
||||
|
||||
button {
|
||||
class: ClassName::BUTTON,
|
||||
style: "background: url(./images/add_user.png) no-repeat center",
|
||||
},
|
||||
|
||||
button {
|
||||
class: ClassName::BUTTON,
|
||||
style: "background: url(./images/tbc_transfert.png) no-repeat center",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Contacts {},
|
||||
|
||||
div {
|
||||
class: ClassName::FOOTER,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
76
src/ui/components/contacts_window/user_infos.rs
Normal file
76
src/ui/components/contacts_window/user_infos.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use dioxus::prelude::*;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::ui::components::avatar_selector::AvatarSelector;
|
||||
use crate::ui::components::icons::DownArrowIcon;
|
||||
|
||||
turf::style_sheet!("src/ui/components/contacts_window/user_infos.scss");
|
||||
|
||||
static MESSAGE_PLACEHOLDER: &str = "<Enter a personal message>";
|
||||
|
||||
pub fn UserInfos() -> Element {
|
||||
debug!("UserInfos rendering");
|
||||
|
||||
// let app_settings = use_atom_ref(cx, &APP_SETTINGS);
|
||||
// let store = &app_settings.read().store;
|
||||
|
||||
// println!("----------------------------------");
|
||||
// println!("UserInfos rendering");
|
||||
// // println!("store={:?}", &store);
|
||||
// dbg!(&store.user_id);
|
||||
// println!("----------------------------------");
|
||||
|
||||
// let user_id = store.user_id..as_ref().unwrap();
|
||||
|
||||
// let mut user_info_option = None;
|
||||
let user_display_name_option: Option<bool> = None;
|
||||
let user_display_name = "AIE";
|
||||
|
||||
// let user_id_option = &store.user_id;
|
||||
// if user_id_option.is_some() {
|
||||
// let user_id = user_id_option.as_ref().unwrap();
|
||||
// let user_info_option = store.user_infos.get(user_id);
|
||||
// if user_info_option.is_some() {
|
||||
// user_display_name_option = user_info_option.unwrap().display_name.as_ref();
|
||||
// }
|
||||
// }
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::USER_INFO,
|
||||
|
||||
div {
|
||||
class: ClassName::AVATAR_SELECTOR,
|
||||
AvatarSelector {},
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::INFO_CONTAINER,
|
||||
|
||||
div {
|
||||
class: ClassName::USER_ID,
|
||||
p {
|
||||
class: ClassName::USER_NAME,
|
||||
if user_display_name_option.is_some() { "{user_display_name}" } else { "AIE" },
|
||||
},
|
||||
p {
|
||||
class: ClassName::USER_STATUS,
|
||||
"(Busy)",
|
||||
},
|
||||
DownArrowIcon {},
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::USER_MESSAGE,
|
||||
p {
|
||||
// TODO: Handle user message
|
||||
{MESSAGE_PLACEHOLDER},
|
||||
}
|
||||
DownArrowIcon {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
63
src/ui/components/contacts_window/user_infos.scss
Normal file
63
src/ui/components/contacts_window/user_infos.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
@import "../../_base.scss"
|
||||
|
||||
.userInfo {
|
||||
position: relative;
|
||||
height: 75%;
|
||||
width: 99%;
|
||||
top: -75%;
|
||||
left: 1%;
|
||||
aspect-ratio: 1;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.avatarSelector {
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.infoContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.userId {
|
||||
@extend .aeroButton;
|
||||
|
||||
height: 30%;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
text-align: begin;
|
||||
align-items: center;
|
||||
|
||||
.userName {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.userStatus {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
color: #B9DDE7;
|
||||
}
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
@extend .aeroButton;
|
||||
|
||||
width: fit-content;
|
||||
height: 30%;
|
||||
display: flex;
|
||||
text-align: begin;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
25
src/ui/components/header.rs
Normal file
25
src/ui/components/header.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
turf::style_sheet!("src/ui/components/header.scss");
|
||||
|
||||
pub fn Header() -> Element {
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::ROOT,
|
||||
img {
|
||||
src: "./images/logo-msn.png"
|
||||
}
|
||||
svg {
|
||||
view_box: "0 0 100 10",
|
||||
text {
|
||||
y: "55%",
|
||||
dominant_baseline: "middle",
|
||||
font_size: "5",
|
||||
"Windows Live Messenger",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
13
src/ui/components/header.scss
Normal file
13
src/ui/components/header.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
.root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: white;
|
||||
}
|
||||
}
|
133
src/ui/components/icons.rs
Normal file
133
src/ui/components/icons.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::md_navigation_icons::MdArrowDropDown;
|
||||
use dioxus_free_icons::{Icon, IconShape};
|
||||
|
||||
turf::style_sheet!("src/ui/components/icons.scss");
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/style_vars.rs"));
|
||||
|
||||
use style::{COLOR_PRIMARY_100, COLOR_TERNARY_100};
|
||||
|
||||
pub fn DownArrowIcon() -> Element {
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
Icon {
|
||||
class: ClassName::DOWN_ARROW_ICON,
|
||||
icon: MdArrowDropDown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
#[derive(PartialEq, Clone)]
|
||||
struct PyramidShape {
|
||||
color: String,
|
||||
ratio: f64,
|
||||
progress_color: String,
|
||||
}
|
||||
|
||||
impl IconShape for PyramidShape {
|
||||
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) -> Element {
|
||||
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(PartialEq, Clone, Props)]
|
||||
pub struct PyramidProps {
|
||||
color: Option<String>,
|
||||
#[props(default = 0.5)]
|
||||
ratio: f64,
|
||||
progress_color: Option<String>,
|
||||
}
|
||||
|
||||
pub fn Pyramid(props: PyramidProps) -> Element {
|
||||
let color = props.color.unwrap_or(COLOR_PRIMARY_100.to_string());
|
||||
let progress_color = props
|
||||
.progress_color
|
||||
.unwrap_or(COLOR_TERNARY_100.to_string());
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
Icon {
|
||||
class: ClassName::PYRAMID_ICON,
|
||||
icon: PyramidShape { ratio: props.ratio, color, progress_color },
|
||||
}
|
||||
}
|
||||
}
|
12
src/ui/components/icons.scss
Normal file
12
src/ui/components/icons.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.down-arrow-icon {
|
||||
color: transparent;
|
||||
|
||||
path:last-child {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
|
||||
.pyramid-icon {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
26
src/ui/components/loading.rs
Normal file
26
src/ui/components/loading.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use dioxus::prelude::*;
|
||||
use tracing::debug;
|
||||
|
||||
use super::spinner::Spinner;
|
||||
use super::wallpaper::Wallpaper;
|
||||
|
||||
turf::style_sheet!("src/ui/components/loading.scss");
|
||||
|
||||
pub fn LoadingPage() -> Element {
|
||||
debug!("LoadingPage rendering");
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::LOADING,
|
||||
|
||||
Wallpaper {},
|
||||
|
||||
div {
|
||||
class: ClassName::LOADING_SPINNER,
|
||||
Spinner {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
80
src/ui/components/loading.scss
Normal file
80
src/ui/components/loading.scss
Normal file
@@ -0,0 +1,80 @@
|
||||
@import "../_base.scss"
|
||||
@import "./spinner.scss"
|
||||
|
||||
.loading {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__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);
|
||||
}
|
||||
}
|
839
src/ui/components/login.rs
Normal file
839
src/ui/components/login.rs
Normal file
@@ -0,0 +1,839 @@
|
||||
use std::borrow::Cow;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use const_format::formatcp;
|
||||
use dioxus::prelude::*;
|
||||
use tracing::{debug, error, warn};
|
||||
use validator::{Validate, ValidateArgs, ValidateEmail, ValidationError, ValidationErrors};
|
||||
use zxcvbn::zxcvbn;
|
||||
|
||||
use crate::base::{Session, SESSION};
|
||||
use crate::infrastructure::services::random_svg_generators::{
|
||||
generate_random_svg_shape, ShapeConfig,
|
||||
};
|
||||
|
||||
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/ui/components/login.scss");
|
||||
|
||||
const BACKGROUND_COLORS_STR: &str = formatcp!(
|
||||
"{COLOR_PRIMARY_150},{COLOR_PRIMARY_140},\
|
||||
{COLOR_SECONDARY_150},{COLOR_SECONDARY_140},\
|
||||
{COLOR_TERNARY_150},{COLOR_TERNARY_140}"
|
||||
);
|
||||
|
||||
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}");
|
||||
|
||||
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(&mut 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(&mut self);
|
||||
fn invalidate(&mut 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: Signal<TextInputState>,
|
||||
}
|
||||
|
||||
impl TextInputHandler {}
|
||||
|
||||
impl OnValidationError for TextInputHandler {
|
||||
fn reset(&mut self) {
|
||||
self.state.write().reset();
|
||||
}
|
||||
|
||||
fn invalidate(&mut self, helper_text: String) {
|
||||
self.state.write().invalidate(helper_text);
|
||||
}
|
||||
|
||||
fn box_clone(&self) -> Box<dyn OnValidationError> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct UrlInputHandler {
|
||||
state: Signal<TextInputState>,
|
||||
}
|
||||
|
||||
impl UrlInputHandler {
|
||||
pub fn new(state: Signal<TextInputState>) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl OnValidationError for UrlInputHandler {
|
||||
fn on_validation_error(&mut 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(&mut self) {
|
||||
self.state.write().reset();
|
||||
}
|
||||
|
||||
fn invalidate(&mut self, helper_text: String) {
|
||||
self.state.write().invalidate(helper_text);
|
||||
}
|
||||
|
||||
fn box_clone(&self) -> Box<dyn OnValidationError> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct EmailInputHandler {
|
||||
state: Signal<TextInputState>,
|
||||
}
|
||||
|
||||
impl EmailInputHandler {
|
||||
pub fn new(state: Signal<TextInputState>) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl OnValidationError for EmailInputHandler {
|
||||
fn on_validation_error(&mut 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(&mut self) {
|
||||
self.state.write().reset();
|
||||
}
|
||||
|
||||
fn invalidate(&mut self, helper_text: String) {
|
||||
self.state.write().invalidate(helper_text);
|
||||
}
|
||||
|
||||
fn box_clone(&self) -> Box<dyn OnValidationError> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PasswordInputHandler {
|
||||
state: Signal<PasswordInputState>,
|
||||
}
|
||||
|
||||
impl PasswordInputHandler {
|
||||
pub fn new(state: Signal<PasswordInputState>) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl OnValidationError for PasswordInputHandler {
|
||||
fn on_validation_error(&mut 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.write().score = score;
|
||||
Some(TOO_WEAK_PASSWORD_ERROR_HELPER_TEXT)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(msg) = msg {
|
||||
self.invalidate(msg.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.state.write().reset();
|
||||
}
|
||||
|
||||
fn invalidate(&mut self, helper_text: String) {
|
||||
self.state.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.borrow_mut().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.borrow_mut().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.borrow_mut().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: &GlobalSignal<Session>, data: Signal<Data>) -> Result<(), ValidationErrors> {
|
||||
let data = data.read();
|
||||
|
||||
match data.validate_with_args(&Process::Login) {
|
||||
Ok(_) => {
|
||||
session.write().update(
|
||||
data.homeserver_url.clone(),
|
||||
data.id.clone(),
|
||||
data.password.clone(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_register(
|
||||
_session: &GlobalSignal<Session>,
|
||||
data: Signal<Data>,
|
||||
) -> Result<(), ValidationErrors> {
|
||||
let data = data.read();
|
||||
|
||||
match data.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, Rc<RefCell<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, Rc::new(RefCell::new(handler)));
|
||||
}
|
||||
fn get(&self, name: &'a str) -> Option<&Rc<RefCell<(dyn OnValidationError + 'static)>>> {
|
||||
if let Some(handler) = self.handlers.get(name) {
|
||||
return Some(handler);
|
||||
}
|
||||
None
|
||||
}
|
||||
fn reset_handlers(&self) {
|
||||
self.handlers.values().for_each(|h| h.borrow_mut().reset());
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! on_input {
|
||||
($data_ref:ident, $data_field:ident) => {
|
||||
move |evt: FormEvent| {
|
||||
let value = evt.value();
|
||||
$data_ref.write().$data_field = if !value.is_empty() { Some(value) } else { None }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! refresh_password_state {
|
||||
($data:ident, $data_field:ident, $state:ident) => {
|
||||
let mut rating = 0.0;
|
||||
if let Some(password) = &$data.peek().$data_field {
|
||||
if let Some(result) = compute_password_score(password, None) {
|
||||
rating = result.rating;
|
||||
}
|
||||
}
|
||||
$state.write().score = rating;
|
||||
};
|
||||
}
|
||||
|
||||
fn generate_modal(
|
||||
config: &PasswordSuggestionsModalConfig,
|
||||
on_confirm: impl FnMut(Event<MouseData>) + 'static,
|
||||
) -> Element {
|
||||
let suggestions = config.suggestions.get(PASSWORD_FIELD_NAME);
|
||||
|
||||
let mut rendered_suggestions = Vec::<Element>::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,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn Login() -> Element {
|
||||
debug!("Login rendering");
|
||||
|
||||
let mut data = use_signal(Data::new);
|
||||
|
||||
let mut current_process = use_signal(|| Process::Login);
|
||||
|
||||
let data_lock = data.read();
|
||||
|
||||
let homeserver_url = data_lock.homeserver_url.as_deref().unwrap_or("");
|
||||
let homeserver_url_state = use_signal(TextInputState::new);
|
||||
let id = data_lock.id.as_deref().unwrap_or("");
|
||||
let id_state = use_signal(TextInputState::new);
|
||||
let password = data_lock.password.as_deref().unwrap_or("");
|
||||
let mut password_state = use_signal(PasswordInputState::new);
|
||||
let confirm_password = data_lock.confirm_password.as_deref().unwrap_or("");
|
||||
let mut confirm_password_state = use_signal(PasswordInputState::new);
|
||||
|
||||
let mut handlers = InputHandlers::new();
|
||||
handlers.insert(
|
||||
HOMESERVER_FIELD_NAME,
|
||||
UrlInputHandler::new(homeserver_url_state),
|
||||
);
|
||||
handlers.insert(ID_FIELD_NAME, EmailInputHandler::new(id_state));
|
||||
handlers.insert(
|
||||
PASSWORD_FIELD_NAME,
|
||||
PasswordInputHandler::new(password_state),
|
||||
);
|
||||
handlers.insert(
|
||||
CONFIRM_PASSWORD_FIELD_NAME,
|
||||
PasswordInputHandler::new(confirm_password_state),
|
||||
);
|
||||
|
||||
let mut spinner_animated = use_signal(|| false);
|
||||
let mut id_placeholder = use_signal(|| LOGIN_ID_PLACEHOLDER);
|
||||
|
||||
let mut random_avatar_future = use_resource(move || async move {
|
||||
let shape_config = ShapeConfig::new(
|
||||
BACKGROUND_COLORS_STR,
|
||||
SHAPE_1_COLORS_STR,
|
||||
SHAPE_2_COLORS_STR,
|
||||
SHAPE_3_COLORS_STR,
|
||||
);
|
||||
generate_random_svg_shape(Some(&shape_config)).await
|
||||
});
|
||||
|
||||
let avatar = match &*random_avatar_future.read_unchecked() {
|
||||
Some(svg) => Some(rsx! {
|
||||
div {
|
||||
class: ClassName::LOGIN_FORM_PHOTO_CONTENT,
|
||||
dangerous_inner_html: svg.as_str(),
|
||||
}
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
|
||||
if *spinner_animated.read() && SESSION.read().is_logged {
|
||||
debug!("Stop spinner");
|
||||
spinner_animated.set(false);
|
||||
}
|
||||
|
||||
refresh_password_state!(data, password, password_state);
|
||||
refresh_password_state!(data, confirm_password, confirm_password_state);
|
||||
|
||||
let mut modal_configs = use_signal(Vec::<PasswordSuggestionsModalConfig>::new);
|
||||
let mut modal_config = use_signal(|| None::<PasswordSuggestionsModalConfig>);
|
||||
|
||||
if !modal_configs.read().is_empty() && modal_config.read().is_none() {
|
||||
modal_config.set(modal_configs.write().pop());
|
||||
}
|
||||
|
||||
let on_clicked_login = {
|
||||
to_owned![handlers];
|
||||
|
||||
move |_| {
|
||||
|
||||
handlers.reset_handlers();
|
||||
|
||||
if *current_process.read() == Process::Registration {
|
||||
current_process.set(Process::Login);
|
||||
data.write().id = None;
|
||||
return;
|
||||
}
|
||||
|
||||
spinner_animated.set(true);
|
||||
|
||||
if let Err(errors) = on_login(&SESSION, data) {
|
||||
let field_errors = errors.field_errors();
|
||||
on_validation_errors(&field_errors, &handlers);
|
||||
}
|
||||
|
||||
spinner_animated.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
let on_clicked_register = {
|
||||
to_owned![handlers, modal_configs];
|
||||
|
||||
move |_| {
|
||||
if *current_process.read() == Process::Login {
|
||||
current_process.set(Process::Registration);
|
||||
data.write().id = None;
|
||||
return;
|
||||
}
|
||||
|
||||
handlers.reset_handlers();
|
||||
|
||||
spinner_animated.set(true);
|
||||
|
||||
if let Err(errors) = on_register(&SESSION, data) {
|
||||
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.read() {
|
||||
Process::Registration => {
|
||||
form_classes[1] = ClassName::REGISTER;
|
||||
password_classes[1] = ClassName::SHOW;
|
||||
confirm_password_classes[1] = ClassName::SHOW;
|
||||
|
||||
if *id_placeholder.read() != REGISTER_ID_PLACEHOLDER {
|
||||
id_placeholder.set(REGISTER_ID_PLACEHOLDER);
|
||||
}
|
||||
}
|
||||
Process::Login => {
|
||||
if *id_placeholder.read() != 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
|
||||
.read()
|
||||
.as_ref()
|
||||
.map(|modal_config| rsx!({ 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(" ");
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
Wallpaper {},
|
||||
|
||||
div {
|
||||
class: ClassName::LOGIN,
|
||||
|
||||
div {
|
||||
class: "{form_classes_str}",
|
||||
|
||||
div {
|
||||
class: ClassName::LOGIN_FORM_PHOTO,
|
||||
|
||||
onclick: move |_| {
|
||||
random_avatar_future.restart()
|
||||
},
|
||||
|
||||
{avatar},
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::LOGIN_FORM_HOMESERVER,
|
||||
TextInput {
|
||||
placeholder: "Homeserver URL",
|
||||
value: "{homeserver_url}",
|
||||
state: homeserver_url_state,
|
||||
oninput: on_input![data, homeserver_url],
|
||||
},
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::LOGIN_FORM_ID,
|
||||
TextInput {
|
||||
placeholder: "{id_placeholder}",
|
||||
value: "{id}",
|
||||
state: id_state,
|
||||
oninput: on_input![data, id],
|
||||
},
|
||||
},
|
||||
|
||||
div {
|
||||
class: "{password_classes_str}",
|
||||
PasswordTextInput {
|
||||
placeholder: "Password",
|
||||
value: "{password}",
|
||||
state: password_state,
|
||||
oninput: on_input![data, password],
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
div {
|
||||
class: "{confirm_password_classes_str}",
|
||||
PasswordTextInput {
|
||||
placeholder: "Confirm Password",
|
||||
value: "{confirm_password}",
|
||||
state: confirm_password_state,
|
||||
oninput: on_input![data, confirm_password],
|
||||
}
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::LOGIN_FORM_SPINNER,
|
||||
Spinner {
|
||||
animate: *spinner_animated.read(),
|
||||
},
|
||||
},
|
||||
|
||||
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},
|
||||
}
|
||||
}
|
135
src/ui/components/login.scss
Normal file
135
src/ui/components/login.scss
Normal file
@@ -0,0 +1,135 @@
|
||||
@import "../_base.scss"
|
||||
@import "./spinner.scss"
|
||||
|
||||
.login {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
position: relative;
|
||||
top: -100vh;
|
||||
|
||||
margin-bottom: -100vh;
|
||||
|
||||
&__form {
|
||||
$height: 95%;
|
||||
height: $height;
|
||||
|
||||
max-height: $form-max-height;
|
||||
aspect-ratio: $form-aspect-ratio;
|
||||
|
||||
border: $border-big;
|
||||
border-color: get-color(primary, 90);
|
||||
border-radius: $border-radius;
|
||||
|
||||
background-color: get-color(greyscale, 0);
|
||||
|
||||
display: grid;
|
||||
|
||||
$padding-col: 5%;
|
||||
$button-height: 8%;
|
||||
$button-overlap: 5%;
|
||||
$profile-img-width: 40%;
|
||||
$edit-padding: 7.5%;
|
||||
$photo-padding: 17.5%;
|
||||
|
||||
$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 ."
|
||||
". . . . . . . . . . ."
|
||||
;
|
||||
|
||||
transition: $transition-duration;
|
||||
|
||||
&.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;
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
}
|
15
src/ui/components/main_window.rs
Normal file
15
src/ui/components/main_window.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use dioxus::prelude::*;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::base::SESSION;
|
||||
use crate::ui::components::contacts_window::ContactsWindow;
|
||||
|
||||
pub fn MainWindow() -> Element {
|
||||
debug!("MainWindow rendering");
|
||||
|
||||
rsx! {
|
||||
if SESSION.read().is_logged {
|
||||
ContactsWindow {}
|
||||
}
|
||||
}
|
||||
}
|
13
src/ui/components/mod.rs
Normal file
13
src/ui/components/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub(crate) mod avatar_selector;
|
||||
pub(crate) mod button;
|
||||
pub(crate) mod chats_window;
|
||||
pub(crate) mod contacts_window;
|
||||
pub(crate) mod header;
|
||||
pub(crate) mod icons;
|
||||
pub(crate) mod loading;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod main_window;
|
||||
pub(crate) mod modal;
|
||||
pub(crate) mod spinner;
|
||||
pub(crate) mod text_input;
|
||||
pub(crate) mod wallpaper;
|
117
src/ui/components/modal.rs
Normal file
117
src/ui/components/modal.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use super::button::{ErrorButton, SuccessButton, WarningButton};
|
||||
|
||||
use crate::infrastructure::services::random_svg_generators::{
|
||||
generate_random_svg_avatar, AvatarConfig, AvatarFeeling,
|
||||
};
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/style_vars.rs"));
|
||||
|
||||
use style::{COLOR_CRITICAL_100, COLOR_SUCCESS_100, COLOR_WARNING_100};
|
||||
|
||||
turf::style_sheet!("src/ui/components/modal.scss");
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash)]
|
||||
pub enum Severity {
|
||||
Ok,
|
||||
Warning,
|
||||
Critical,
|
||||
}
|
||||
|
||||
fn avatar_configs() -> &'static HashMap<Severity, AvatarConfig<'static>> {
|
||||
static HASHMAP: OnceLock<HashMap<Severity, AvatarConfig>> = OnceLock::new();
|
||||
HASHMAP.get_or_init(|| {
|
||||
let mut configs = HashMap::new();
|
||||
configs.insert(
|
||||
Severity::Critical,
|
||||
AvatarConfig::new(AvatarFeeling::Alerting, COLOR_CRITICAL_100),
|
||||
);
|
||||
configs.insert(
|
||||
Severity::Warning,
|
||||
AvatarConfig::new(AvatarFeeling::Warning, COLOR_WARNING_100),
|
||||
);
|
||||
configs.insert(
|
||||
Severity::Ok,
|
||||
AvatarConfig::new(AvatarFeeling::Ok, COLOR_SUCCESS_100),
|
||||
);
|
||||
configs
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
pub struct ModalProps {
|
||||
pub severity: Severity,
|
||||
#[props(optional)]
|
||||
pub title: Option<String>,
|
||||
pub children: Element,
|
||||
#[props(optional)]
|
||||
pub on_confirm: Option<EventHandler<MouseEvent>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Modal(props: ModalProps) -> Element {
|
||||
let avatar_config = avatar_configs().get(&props.severity);
|
||||
|
||||
let random_figure_future =
|
||||
use_resource(move || async move { generate_random_svg_avatar(avatar_config).await });
|
||||
|
||||
let icon = match &*random_figure_future.read_unchecked() {
|
||||
Some(svg) => Some(rsx! {
|
||||
div {
|
||||
class: ClassName::MODAL_CONTENT_ICON_PLACEHOLDER,
|
||||
dangerous_inner_html: svg.as_str(),
|
||||
}
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let button_class = match &props.severity {
|
||||
Severity::Ok => SuccessButton,
|
||||
Severity::Warning => WarningButton,
|
||||
Severity::Critical => ErrorButton,
|
||||
};
|
||||
|
||||
icon.as_ref()?;
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::MODAL,
|
||||
|
||||
div {
|
||||
class: ClassName::MODAL_CONTENT,
|
||||
|
||||
div {
|
||||
class: ClassName::MODAL_CONTENT_ICON,
|
||||
{icon}
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::MODAL_CONTENT_TITLE,
|
||||
{props.title},
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::MODAL_CONTENT_MSG,
|
||||
{props.children},
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::MODAL_CONTENT_BUTTONS,
|
||||
button_class {
|
||||
onclick: move |evt| {
|
||||
if let Some(cb) = &props.on_confirm {
|
||||
cb.call(evt);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
111
src/ui/components/modal.scss
Normal file
111
src/ui/components/modal.scss
Normal file
@@ -0,0 +1,111 @@
|
||||
@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));
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 105%;
|
||||
}
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
}
|
45
src/ui/components/spinner.rs
Normal file
45
src/ui/components/spinner.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::{Icon, IconShape};
|
||||
|
||||
turf::style_sheet!("src/ui/components/spinner.scss");
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct _Spinner;
|
||||
impl IconShape for _Spinner {
|
||||
fn view_box(&self) -> String {
|
||||
String::from("0 0 184 94")
|
||||
}
|
||||
fn xmlns(&self) -> String {
|
||||
String::from("http://www.w3.org/2000/svg")
|
||||
}
|
||||
fn child_elements(&self) -> Element {
|
||||
rsx! {
|
||||
path {
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "6",
|
||||
d: "M121.208 2 2 57.011l70.927-.265L61.363 92 182 36.724h-69.498L121.208 2Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Props)]
|
||||
pub struct SpinnerProps {
|
||||
#[props(default = true)]
|
||||
animate: bool,
|
||||
}
|
||||
|
||||
pub fn Spinner(props: SpinnerProps) -> Element {
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::SPINNER,
|
||||
|
||||
Icon {
|
||||
class: if props.animate { "" } else { ClassName::PAUSED },
|
||||
icon: _Spinner,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
44
src/ui/components/spinner.scss
Normal file
44
src/ui/components/spinner.scss
Normal file
@@ -0,0 +1,44 @@
|
||||
@import "../_base.scss"
|
||||
|
||||
$background-height: 128px;
|
||||
$background-width: 384px;
|
||||
$logo-height: calc(32px * 2);
|
||||
$logo-width: calc(64px * 2);
|
||||
$logo-aspect-ratio: calc($logo-width / $logo-height);
|
||||
|
||||
.spinner {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
svg {
|
||||
$fps: 4;
|
||||
$duration_sec: 3;
|
||||
$steps: calc($duration_sec * $fps);
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
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: get-color(primary, 100);
|
||||
}
|
||||
33% {
|
||||
fill: get-color(secondary, 100);
|
||||
}
|
||||
66% {
|
||||
fill: get-color(ternary, 100);
|
||||
}
|
||||
}
|
||||
|
||||
&.paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
218
src/ui/components/text_input.rs
Normal file
218
src/ui/components/text_input.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
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/ui/components/text_input.scss");
|
||||
|
||||
pub trait InputPropsData {}
|
||||
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
pub struct InputProps<D: InputPropsData + 'static + std::cmp::PartialEq> {
|
||||
value: Option<String>,
|
||||
placeholder: Option<String>,
|
||||
oninput: Option<EventHandler<Event<FormData>>>,
|
||||
state: Option<Signal<D>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, 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(props: InputProps<TextInputState>) -> Element {
|
||||
let mut criticity_class = "";
|
||||
let mut helper_text = "".to_string();
|
||||
|
||||
if let Some(state) = 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(" ");
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: ClassName::TEXT_INPUT,
|
||||
|
||||
input {
|
||||
class: "{input_classes_str}",
|
||||
r#type: "text",
|
||||
placeholder: props.placeholder,
|
||||
value: props.value,
|
||||
|
||||
oninput: move |evt| {
|
||||
if let Some(cb) = &props.oninput {
|
||||
cb.call(evt);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::TEXT_INPUT_HELPER_TEXT,
|
||||
|
||||
p {
|
||||
class: criticity_class,
|
||||
{helper_text}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
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(props: InputProps<PasswordInputState>) -> Element {
|
||||
let mut criticity_class = "";
|
||||
let mut helper_text: String = "".to_string();
|
||||
let mut score: Option<f64> = None;
|
||||
|
||||
let mut show_password = use_signal(|| false);
|
||||
|
||||
if let Some(state) = 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(" ");
|
||||
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
|
||||
div {
|
||||
class: "{text_input_classes}",
|
||||
|
||||
input {
|
||||
class: "{input_classes}",
|
||||
r#type: if *show_password.read() { "text" } else { "password" },
|
||||
placeholder: props.placeholder,
|
||||
value: props.value,
|
||||
|
||||
oninput: move |evt| {
|
||||
if let Some(cb) = &props.oninput {
|
||||
cb.call(evt);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
if let Some(score) = score {
|
||||
div {
|
||||
class: ClassName::PASSWORD_TEXT_INPUT_STRENGTH_LEVEL,
|
||||
Pyramid {
|
||||
ratio: score,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::PASSWORD_TEXT_INPUT_SHOW_TOGGLE,
|
||||
onclick: move |_| {
|
||||
let current_state = *show_password.read();
|
||||
show_password.set(!current_state);
|
||||
},
|
||||
|
||||
if *show_password.read() {
|
||||
Icon {
|
||||
icon: IoEyeOff,
|
||||
}
|
||||
}
|
||||
else {
|
||||
Icon {
|
||||
icon: IoEye,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
div {
|
||||
class: ClassName::PASSWORD_TEXT_INPUT_HELPER_TEXT,
|
||||
|
||||
p {
|
||||
class: criticity_class,
|
||||
{helper_text}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
130
src/ui/components/text_input.scss
Normal file
130
src/ui/components/text_input.scss
Normal file
@@ -0,0 +1,130 @@
|
||||
@import "../_base.scss"
|
||||
|
||||
%base-text-input {
|
||||
$horizontal-padding: 1vw;
|
||||
|
||||
height: 100%;
|
||||
width: calc(100% - (2 * $horizontal-padding));
|
||||
|
||||
border: $border-normal;
|
||||
border-color: get-color(primary, 90);
|
||||
border-radius: $border-radius;
|
||||
|
||||
padding-left: $horizontal-padding;
|
||||
padding-right: $horizontal-padding;
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
%base-input {
|
||||
$horizontal-padding: 1vw;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
font-size: 2vh;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.invalid {
|
||||
border-color: get-color(critical, 100);
|
||||
}
|
||||
}
|
||||
|
||||
%base-helper-text {
|
||||
margin: 0;
|
||||
margin-top: 0.3vh;
|
||||
|
||||
font-size: 1.2vh;
|
||||
|
||||
color: get-color(primary, 90);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
|
||||
&.invalid {
|
||||
color: get-color(critical, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-input {
|
||||
@extend %base-text-input;
|
||||
|
||||
&__input {
|
||||
@extend %base-input;
|
||||
}
|
||||
|
||||
&__helper-text {
|
||||
@extend %base-helper-text;
|
||||
}
|
||||
}
|
||||
|
||||
.password-text-input {
|
||||
@extend %base-text-input;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto 7.5% 1% 7.5%;
|
||||
grid-template-rows: 100%;
|
||||
grid-template-areas:
|
||||
"input strength . toggle"
|
||||
"helper helper helper helper"
|
||||
;
|
||||
|
||||
transition: $transition-duration;
|
||||
|
||||
&.no-strength {
|
||||
grid-template-columns: auto 0% 0% 7.5%;
|
||||
}
|
||||
|
||||
&__input {
|
||||
@extend %base-input;
|
||||
|
||||
grid-area: input;
|
||||
}
|
||||
|
||||
%inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__strength-level {
|
||||
@extend %inner;
|
||||
|
||||
grid-area: strength;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
font-size: 2vh;
|
||||
}
|
||||
}
|
||||
|
||||
&__show-toggle {
|
||||
@extend %inner;
|
||||
|
||||
grid-area: toggle;
|
||||
|
||||
svg {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
color: get-color(secondary, 100),
|
||||
}
|
||||
}
|
||||
|
||||
&__helper-text {
|
||||
@extend %base-helper-text;
|
||||
|
||||
grid-area: helper;
|
||||
}
|
||||
}
|
16
src/ui/components/wallpaper.rs
Normal file
16
src/ui/components/wallpaper.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
turf::style_sheet!("src/ui/components/wallpaper.scss");
|
||||
|
||||
pub fn Wallpaper() -> Element {
|
||||
rsx! {
|
||||
style { {STYLE_SHEET} },
|
||||
div {
|
||||
class: ClassName::WALLPAPER,
|
||||
|
||||
div {
|
||||
class: ClassName::WALLPAPER_CONTENT,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
src/ui/components/wallpaper.scss
Normal file
21
src/ui/components/wallpaper.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
@import "../_base.scss"
|
||||
|
||||
.wallpaper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
|
||||
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%;
|
||||
}
|
||||
}
|
1
src/ui/mod.rs
Normal file
1
src/ui/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(crate) mod components;
|
Reference in New Issue
Block a user