diff --git a/images/modal-default-critical-icon.svg b/images/modal-default-critical-icon.svg
new file mode 100644
index 0000000..27c0b81
--- /dev/null
+++ b/images/modal-default-critical-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/images/modal-default-ok-icon.svg b/images/modal-default-ok-icon.svg
new file mode 100644
index 0000000..bf525e9
--- /dev/null
+++ b/images/modal-default-ok-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/images/modal-default-warning-icon.svg b/images/modal-default-warning-icon.svg
new file mode 100644
index 0000000..e312db4
--- /dev/null
+++ b/images/modal-default-warning-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/mod.rs b/src/components/mod.rs
index fadbf2d..a429ed3 100644
--- a/src/components/mod.rs
+++ b/src/components/mod.rs
@@ -7,6 +7,7 @@ pub mod icons;
pub mod loading;
pub mod login;
pub mod main_window;
+pub mod modal;
pub mod spinner;
pub mod text_input;
pub mod wallpaper;
diff --git a/src/components/modal.rs b/src/components/modal.rs
new file mode 100644
index 0000000..96cdf86
--- /dev/null
+++ b/src/components/modal.rs
@@ -0,0 +1,231 @@
+use std::collections::HashMap;
+use std::fmt;
+use std::sync::OnceLock;
+
+use dioxus::prelude::*;
+use rand::distributions::{Alphanumeric, DistString};
+use tracing::{error, warn};
+
+use super::button::{ErrorButton, SuccessButton, WarningButton};
+
+include!(concat!(env!("OUT_DIR"), "/style_vars.rs"));
+
+use style::{COLOR_CRITICAL_100, COLOR_SUCCESS_100, COLOR_WARNING_100};
+
+turf::style_sheet!("src/components/modal.scss");
+
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub enum Severity {
+ Ok,
+ Warning,
+ Critical,
+}
+impl fmt::Display for Severity {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ let repr = match self {
+ Self::Ok => "Severity::Ok",
+ Self::Warning => "Severity::Warning",
+ Self::Critical => "Severity::Critical",
+ };
+ write!(f, "{repr}")
+ }
+}
+
+struct DicebearConfig<'a> {
+ gesture: &'a str,
+ color: &'a str,
+ browns: Vec,
+ eyes: Vec,
+ lips: Vec,
+}
+
+#[derive(Props)]
+pub struct ModalProps<'a> {
+ pub severity: Severity,
+ #[props(optional)]
+ pub title: Option<&'a str>,
+ pub children: Element<'a>,
+ #[props(optional)]
+ pub on_confirm: Option>,
+}
+
+fn dicebear_variants() -> &'static HashMap> {
+ static HASHMAP: OnceLock> = OnceLock::new();
+ HASHMAP.get_or_init(|| {
+ let mut variants = HashMap::new();
+ variants.insert(
+ Severity::Critical,
+ DicebearConfig {
+ gesture: "wavePointLongArms",
+ color: COLOR_CRITICAL_100,
+ browns: vec![2, 6, 11, 13],
+ eyes: vec![2, 4],
+ lips: vec![1, 2, 7, 11, 19, 20, 24, 27],
+ },
+ );
+ variants.insert(
+ Severity::Warning,
+ DicebearConfig {
+ gesture: "pointLongArm",
+ color: COLOR_WARNING_100,
+ browns: vec![2, 5, 10, 13],
+ eyes: vec![1, 3],
+ lips: vec![1, 2, 4, 8, 10, 13, 18, 21, 29],
+ },
+ );
+ variants.insert(
+ Severity::Ok,
+ DicebearConfig {
+ gesture: "okLongArm",
+ color: COLOR_SUCCESS_100,
+ browns: vec![1, 3, 4, 7, 8, 9, 12],
+ eyes: vec![5],
+ lips: vec![3, 5, 9, 14, 17, 22, 23, 25, 30],
+ },
+ );
+
+ variants
+ })
+}
+
+fn render_dicebear_variants(values: &[u32]) -> String {
+ values
+ .iter()
+ .map(|l| format!("variant{:02}", l))
+ .collect::>()
+ .join(",")
+}
+
+async fn generate_random_figure(url: &String, severity: Severity) -> Option {
+ let mut res: Option = None;
+
+ let config = match dicebear_variants().get(&severity) {
+ Some(config) => config,
+ None => {
+ error!("No dicebear configuration found for \"{severity}\"");
+ return res;
+ }
+ };
+
+ let seed = Alphanumeric.sample_string(&mut rand::thread_rng(), 16);
+
+ let color = config.color;
+ let gesture = config.gesture;
+ let rendered_browns = render_dicebear_variants(&config.browns);
+ let rendered_eyes = render_dicebear_variants(&config.eyes);
+ let rendered_lips = render_dicebear_variants(&config.lips);
+
+ let req = format!(
+ "https://{url}/7.x/notionists/svg?\
+ seed={seed}&\
+ backgroundColor={color}&\
+ gestureProbability=100&gesture={gesture}&\
+ browsProbability=100&brows={rendered_browns}&\
+ eyesProbability=100&eyes={rendered_eyes}&\
+ lipsProbability=100&lips={rendered_lips}"
+ );
+
+ match reqwest::get(req).await {
+ Ok(result) => {
+ match result.text().await {
+ Ok(svg) => {
+ res = Some(svg);
+ }
+ Err(err) => {
+ error!("Error during placeholder loading: {}", err);
+ }
+ };
+ }
+ Err(err) => {
+ error!("Error during placeholder loading: {}", err);
+ }
+ };
+ res
+}
+
+#[component]
+pub fn Modal<'a>(cx: Scope<'a, ModalProps<'a>>) -> Element<'a> {
+ // TODO: Use configuration file
+ let url = "dicebear.tools.adrien.run".to_string();
+
+ let severity = cx.props.severity.clone();
+
+ let random_figure_future = use_future(cx, &url, |url| async move {
+ generate_random_figure(&url, severity).await
+ });
+
+ let figure = match random_figure_future.value() {
+ Some(Some(svg)) => Some(rsx! {
+ div {
+ class: ClassName::MODAL_CONTENT_ICON_PLACEHOLDER,
+ dangerous_inner_html: svg.as_str(),
+ }
+ }),
+ Some(None) => {
+ warn!("No profile image set or generated, display the placeholder");
+ let path = match cx.props.severity {
+ Severity::Ok => "./images/modal-default-ok-icon.svg",
+ Severity::Warning => "./images/modal-default-warning-icon.svg",
+ Severity::Critical => "./images/modal-default-critical-icon.svg",
+ };
+ Some(rsx! {
+ div {
+ class: ClassName::MODAL_CONTENT_ICON_PLACEHOLDER,
+ img {
+ src: path
+ }
+ }
+ })
+ }
+ None => None,
+ };
+
+ let button_class = match cx.props.severity {
+ Severity::Ok => SuccessButton,
+ Severity::Warning => WarningButton,
+ Severity::Critical => ErrorButton,
+ };
+
+ if figure.is_none() {
+ return None;
+ }
+
+ cx.render(rsx! {
+ style { STYLE_SHEET },
+
+ div {
+ class: ClassName::MODAL,
+
+ div {
+ class: ClassName::MODAL_CONTENT,
+
+ div {
+ class: ClassName::MODAL_CONTENT_ICON,
+ {figure}
+ },
+
+ div {
+ class: ClassName::MODAL_CONTENT_TITLE,
+ cx.props.title,
+ },
+
+ div {
+ class: ClassName::MODAL_CONTENT_MSG,
+
+ &cx.props.children,
+ },
+
+ div {
+ class: ClassName::MODAL_CONTENT_BUTTONS,
+ button_class {
+ onclick: move |evt| {
+ if let Some(cb) = &cx.props.on_confirm {
+ cb.call(evt);
+ }
+ },
+ },
+ },
+ },
+ },
+ })
+}
diff --git a/src/components/modal.scss b/src/components/modal.scss
new file mode 100644
index 0000000..5c3bc9f
--- /dev/null
+++ b/src/components/modal.scss
@@ -0,0 +1,107 @@
+@import "../_base.scss"
+
+$modal-min-height: 15vh;
+$modal-max-height: 55vh;
+
+.modal {
+ width: 100%;
+ height: 100%;
+
+ position: relative;
+ top: -100%;
+ margin-bottom: -100%;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ background: rgba(0, 0, 0, 0.5);
+
+ &__content {
+ $content-width: 70vw;
+ $title-height: 3vh;
+ $space-height: 1vh;
+ $buttons-height: 5vh;
+ $icon-width: 10vw;
+ $msg-max-width: 50vw;
+
+ $msg-min-height: calc($modal-min-height - $title-height - $space-height - $buttons-height);
+ $msg-max-height: calc($modal-max-height - $title-height - $space-height - $buttons-height);
+
+ min-height: $modal-min-height;
+ max-height: $modal-max-height;
+
+ width: min-content;
+
+ display: grid;
+ grid-template-columns: $icon-width 1vw minmax(max-content, $msg-max-width);
+ grid-template-rows: $title-height $space-height max-content $space-height $buttons-height;
+ grid-template-areas:
+ "icon . title"
+ "icon . ."
+ "icon . msg"
+ ". . ."
+ "buttons buttons buttons"
+ ;
+
+ padding: 2.5%;
+
+ background: get-color(greyscale, 0);
+
+ border: $border-normal;
+ border-radius: $border-radius;
+ border-color: get-color(greyscale, 90);
+
+ &__icon {
+ grid-area: icon;
+
+ aspect-ratio: 1;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ overflow: hidden;
+
+ border: $border-normal;
+ border-radius: $border-radius;
+
+ &__placeholder {
+ width: calc(100% + (2 * $border-normal-width));
+ height: calc(100% - (2 * $border-normal-width));
+ }
+ }
+
+ &__title {
+ grid-area: title;
+
+ width: max-content;
+
+ font-size: calc($title-height - 0.5vh);
+ font-weight: bold;
+ }
+
+ &__msg {
+ grid-area: msg;
+
+ height: fit-content;
+
+ min-height: $msg-min-height;
+ max-height: $msg-max-height;
+ max-width: $msg-max-width;
+
+ overflow: scroll;
+
+ font-size: 1.5vh;
+ color: get-color(greyscale, 80);
+ }
+
+ &__buttons {
+ grid-area: buttons;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ }
+}