🎨 Isolate infra and ui components

The src/base.rs is still to be reworked.
This commit is contained in:
2024-04-04 14:27:58 +02:00
parent 92bf860101
commit 0ce0764204
67 changed files with 64 additions and 59 deletions

246
src/ui/_base.scss Normal file
View 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;
}

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

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

View File

@@ -0,0 +1,60 @@
@import "../_base.scss"
%button {
height: 100%;
aspect-ratio: 3.5;
border: $border-normal;
border-radius: $border-radius;
color: get-color(greyscale, 0);
font-family: "Geist";
font-weight: bold;
svg {
height: 100%;
width: 100%;
text {
font-size: 50;
text-anchor: middle;
dominant-baseline: central;
fill: get-color(greyscale, 0);
}
}
}
@mixin button($color-name, $color-level) {
@extend %button;
background-color: get_color($color-name, $color-level);
&:hover {
background-color: get_color($color-name, calc($color-level - 10));
}
&:active {
background-color: get_color($color-name, calc($color-level - 20));
}
}
.register-button {
@include button(ternary, 90);
}
.login-button {
@include button(secondary, 90);
}
.success-button {
@include button(success, 100);
}
.warning-button {
@include button(warning, 100);
}
.error-button {
@include button(critical, 100);
}

View File

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

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

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

View 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 {
"🔎"
},
},
},
}
}

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

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

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

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

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

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

View File

@@ -0,0 +1,6 @@
@import "../../_base.scss"
.contacts {
height: 72%;
background-color: white;
}

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,12 @@
.down-arrow-icon {
color: transparent;
path:last-child {
fill: white;
}
}
.pyramid-icon {
height: 100%;
width: 100%;
}

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,130 @@
@import "../_base.scss"
%base-text-input {
$horizontal-padding: 1vw;
height: 100%;
width: calc(100% - (2 * $horizontal-padding));
border: $border-normal;
border-color: get-color(primary, 90);
border-radius: $border-radius;
padding-left: $horizontal-padding;
padding-right: $horizontal-padding;
padding-top: 0px;
padding-bottom: 0px;
}
%base-input {
$horizontal-padding: 1vw;
height: 100%;
width: 100%;
margin: 0;
padding: 0;
border: 0;
font-size: 2vh;
&:focus {
outline: none;
}
&.invalid {
border-color: get-color(critical, 100);
}
}
%base-helper-text {
margin: 0;
margin-top: 0.3vh;
font-size: 1.2vh;
color: get-color(primary, 90);
p {
margin: 0;
&.invalid {
color: get-color(critical, 100);
}
}
}
.text-input {
@extend %base-text-input;
&__input {
@extend %base-input;
}
&__helper-text {
@extend %base-helper-text;
}
}
.password-text-input {
@extend %base-text-input;
display: grid;
grid-template-columns: auto 7.5% 1% 7.5%;
grid-template-rows: 100%;
grid-template-areas:
"input strength . toggle"
"helper helper helper helper"
;
transition: $transition-duration;
&.no-strength {
grid-template-columns: auto 0% 0% 7.5%;
}
&__input {
@extend %base-input;
grid-area: input;
}
%inner {
display: flex;
align-items: center;
justify-content: center;
}
&__strength-level {
@extend %inner;
grid-area: strength;
p {
margin: 0;
text-align: center;
width: 100%;
font-size: 2vh;
}
}
&__show-toggle {
@extend %inner;
grid-area: toggle;
svg {
height: 100%;
width: 100%;
color: get-color(secondary, 100),
}
}
&__helper-text {
@extend %base-helper-text;
grid-area: helper;
}
}

View File

@@ -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,
}
}
}
}

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

@@ -0,0 +1 @@
pub(crate) mod components;