diff --git a/Cargo.toml b/Cargo.toml index 25f9967..4c24204 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,9 @@ edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +csv = "1.4.0" +dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } +gloo-timers = { version = "0.3.0", features = ["futures"], optional = true } chacha20poly1305 = { version = "0.10.1", optional = true } ciborium = { version = "0.2.2", optional = true } dioxus = { version = "0.7.3", features = ["fullstack"] } @@ -22,8 +25,9 @@ uuid = { version = "1.19.0", features = ["v4", "serde"], optional = true } zeroize = { version = "1.8.2", optional = true } [features] -default = [] -web = ["dioxus/web"] +default = ["web"] +web = ["dioxus/web", "dep:gloo-timers"] +desktop = ["dioxus/desktop", "dep:tokio"] # desktop = ["dioxus/desktop"] # mobile = ["dioxus/mobile"] server = [ diff --git a/Makefile b/Makefile index 35fec7a..ee1ad89 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,22 @@ serve-no-state: build: dx bundle ${dx-args} -bundle: +web-bundle: + -rm -r web-apollo.zip target/dx # would bloat otherwise dx bundle --release ${dx-args} + cp -r target/dx/apollo/release/web/public . + -rm web-apollo.zip + zip web-apollo public/* public/assets/* + rm -r public + unzip -l web-apollo.zip + +server-build: + @echo 'probably `ulimit -n 1024`' # needed on my mac for sure + cargo zigbuild --release --target x86_64-unknown-linux-gnu --no-default-features --features server_state_save,web + cp target/x86_64-unknown-linux-gnu/release/apollo apollo-x86_64-linux-gnu + +bundle: web-bundle server-build + @echo "scp apollo-x86_64-linux-gnu web-apollo.zip " clean: cargo clean @@ -20,6 +34,8 @@ clean: help list: @echo "*serve*: build, run and reload on changes" @echo "serve-no-state: build, run and reload on changes, don't save server state" - @echo "build: build in debug mode" - @echo "bundle: build in release mode" + @echo "build: bundle in debug mode" + @echo "web-bundle: bundle the web-client" + @echo "server-build: build in release mode for x64-linux" + @echo "bundle: web-bundle and server-build" @echo "clean: clean target" diff --git a/assets/dx-components-theme.css b/assets/dx-components-theme.css new file mode 100644 index 0000000..a0ab6cc --- /dev/null +++ b/assets/dx-components-theme.css @@ -0,0 +1,83 @@ +/* This file contains the global styles for the styled dioxus components. You only + * need to import this file once in your project root. + */ +@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"); + +body { + padding: 0; + margin: 20px; + background-color: var(--bg); + color: var(--light1); + font-family: Inter, sans-serif; + font-optical-sizing: auto; + font-style: normal; + font-weight: 400; +} + +@media (prefers-color-scheme: dark) { + :root { + --dark: initial; + --light: ; + } +} + +@media (prefers-color-scheme: light) { + :root { + --dark: ; + --light: initial; + } +} + +:root { + /* Primary colors */ + --primary-color: var(--dark, #000) var(--light, #fff); + --primary-color-1: var(--dark, #0e0e0e) var(--light, #fbfbfb); + --primary-color-2: var(--dark, #0a0a0a) var(--light, #fff); + --primary-color-3: var(--dark, #141313) var(--light, #f8f8f8); + --primary-color-4: var(--dark, #1a1a1a) var(--light, #f8f8f8); + --primary-color-5: var(--dark, #262626) var(--light, #f5f5f5); + --primary-color-6: var(--dark, #232323) var(--light, #e5e5e5); + --primary-color-7: var(--dark, #3e3e3e) var(--light, #b0b0b0); + + /* Secondary colors */ + --secondary-color: var(--dark, #fff) var(--light, #000); + --secondary-color-1: var(--dark, #fafafa) var(--light, #000); + --secondary-color-2: var(--dark, #e6e6e6) var(--light, #0d0d0d); + --secondary-color-3: var(--dark, #dcdcdc) var(--light, #2b2b2b); + --secondary-color-4: var(--dark, #d4d4d4) var(--light, #111); + --secondary-color-5: var(--dark, #a1a1a1) var(--light, #848484); + --secondary-color-6: var(--dark, #5d5d5d) var(--light, #d0d0d0); + + /* Highlight colors */ + --focused-border-color: var(--dark, #2b7fff) var(--light, #2b7fff); + --primary-success-color: var(--dark, #02271c) var(--light, #ecfdf5); + --secondary-success-color: var(--dark, #b6fae3) var(--light, #10b981); + --primary-warning-color: var(--dark, #342203) var(--light, #fffbeb); + --secondary-warning-color: var(--dark, #feeac7) var(--light, #f59e0b); + --primary-error-color: var(--dark, #a22e2e) var(--light, #dc2626); + --secondary-error-color: var(--dark, #9b1c1c) var(--light, #ef4444); + --contrast-error-color: var(--dark, var(--secondary-color-3)) + var(--light, var(--primary-color)); + --primary-info-color: var(--dark, var(--primary-color-5)) + var(--light, var(--primary-color)); + --secondary-info-color: var(--dark, var(--primary-color-7)) + var(--light, var(--secondary-color-3)); +} + +/* Modern browsers with `scrollbar-*` support */ +@supports (scrollbar-width: auto) { + :not(:hover) { + scrollbar-color: rgb(0 0 0 / 0%) rgb(0 0 0 / 0%); + } + + :hover { + scrollbar-color: var(--secondary-color-2) rgb(0 0 0 / 0%); + } +} + +/* Legacy browsers with `::-webkit-scrollbar-*` support */ +@supports selector(::-webkit-scrollbar) { + :root::-webkit-scrollbar-track { + background: transparent; + } +} diff --git a/assets/favicon.ico b/assets/favicon.ico index eed0c09..d8b92f6 100644 Binary files a/assets/favicon.ico and b/assets/favicon.ico differ diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..31901f8 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,108 @@ + + + + + + + + + + + + ? + + + + + ? + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/header.svg b/assets/header.svg deleted file mode 100644 index 59c96f2..0000000 --- a/assets/header.svg +++ /dev/null @@ -1,20 +0,0 @@ - \ No newline at end of file diff --git a/assets/main.css b/assets/main.css index 4314613..5333dc2 100644 --- a/assets/main.css +++ b/assets/main.css @@ -1,107 +1,90 @@ -/* App-wide styling */ -body { - background-color: #0f1116; - color: #ffffff; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - margin: 20px; -} - -#hero { - margin: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; +.normal .others-container { + display: block; } -#links { - width: 400px; - text-align: left; - font-size: x-large; - color: white; - display: flex; - flex-direction: column; +.table-only .others-container { + display: none !important; } -#links a { - color: white; - text-decoration: none; - margin-top: 20px; - margin: 10px 0px; - border: white 1px solid; - border-radius: 5px; - padding: 10px; +.table-only .table-container table { + position: fixed; + margin: auto; + inset: 0; + width: 80vw; + height: 80vh; } -#links a:hover { - background-color: #1f1f1f; - cursor: pointer; +.loading { + position: fixed; + inset: 0; + display: grid; + place-items: center; + width: 100vw; + height: 100vh; } -#header { - max-width: 1200px; +#msgerr.popup { + border: solid 2px darkred; } -/* Navbar */ -#navbar { - display: flex; - flex-direction: row; - } - -#navbar a { - color: #ffffff; - margin-right: 20px; - text-decoration: none; - transition: color 0.2s ease; +#msgnorm.popup { + border: solid 2px steelblue; } -#navbar a:hover { - cursor: pointer; - color: #91a4d2; +.popup { + width: auto; + height: auto; + position: fixed; + top: 30px; + right: 30px; + background: #333; + color: white; + padding: 18px 30px; + font-size: 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + animation: + fadein 0.2s ease-out, + fadeout 0.3s ease-in 2.7s forwards; } -/* Blog page */ -#blog { - margin-top: 50px; - } - -#blog a { - color: #ffffff; - margin-top: 50px; +@keyframes fadein { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } } -/* Echo */ -#echo { - width: 360px; - margin-left: auto; - margin-right: auto; - margin-top: 50px; - background-color: #1e222d; - padding: 20px; - border-radius: 10px; +@keyframes fadeout { + to { + opacity: 0; + transform: translateY(-10px); + } } -#echo>h4 { - margin: 0px 0px 15px 0px; +body { + background-color: var(--bg); + color: var(--light1); + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + margin: 20px; } - -#echo>input { - border: none; - border-bottom: 1px white solid; - background-color: transparent; - color: #ffffff; - transition: border-bottom-color 0.2s ease; - outline: none; - display: block; - padding: 0px 0px 5px 0px; - width: 100%; +/* App-wide styling */ +table, +th, +td, +tr { + border: solid 2px var(--middle); + border-collapse: collapse; + color: var(--light1); } -#echo>input:focus { - border-bottom-color: #6d85c6; +td { + min-width: 10rem; + height: 50px; + /*text-align: center;*/ } - -#echo>p { - margin: 20px 0px 0px auto; -} \ No newline at end of file diff --git a/assets/translations.yml b/assets/translations.yml index 479b748..8b092a4 100644 --- a/assets/translations.yml +++ b/assets/translations.yml @@ -11,10 +11,10 @@ terms: # prepare-startup env-var-not-set: en: env var "{$key}" not set, can't proceed - hu: nincs beállítva a "{$key}" környezeti változó, feladjuk + hu: nincs beállítva a(z) "{$key}" környezeti változó, feladjuk empty-env-var: en: env var "{$key}" empty, can't proceed - hu: a "{$key}" környezeti változó üres, feladjuk + hu: a(z) "{$key}" környezeti változó üres, feladjuk state-load-err: en: "couldn't load saved state due tue: {$error}, exiting..." hu: "nem sikerült betölteni az elmentett állapotot: {$error}, feladjuk" diff --git a/backend_mock_data.patch b/backend_mock_data.patch new file mode 100644 index 0000000..2db96a2 --- /dev/null +++ b/backend_mock_data.patch @@ -0,0 +1,62 @@ +diff --git a/src/backend/logic.rs b/src/backend/logic.rs +index 6b5f509..2bf923e 100644 +--- a/src/backend/logic.rs ++++ b/src/backend/logic.rs +@@ -3,11 +3,53 @@ use dioxus::prelude::*; + use std::{env, process}; + use tokio::sync::RwLock; + +-pub(super) static PUZZLES: LazyLock> = +- LazyLock::new(|| RwLock::new(PuzzleSolutions::new())); ++pub(super) static PUZZLES: LazyLock> = LazyLock::new(|| { ++ RwLock::new(PuzzleSolutions::from([ ++ ( ++ "1".to_string(), ++ Puzzle { ++ solution: "10".to_string(), ++ value: 100, ++ }, ++ ), ++ ( ++ "2".to_string(), ++ Puzzle { ++ solution: "20".to_string(), ++ value: 200, ++ }, ++ ), ++ ( ++ "3".to_string(), ++ Puzzle { ++ solution: "30".to_string(), ++ value: 300, ++ }, ++ ), ++ ( ++ "4".to_string(), ++ Puzzle { ++ solution: "40".to_string(), ++ value: 400, ++ }, ++ ), ++ ])) ++}); + +-pub(super) static TEAMS: LazyLock> = +- LazyLock::new(|| RwLock::new(TeamsState::new())); ++pub(super) static TEAMS: LazyLock> = LazyLock::new(|| { ++ RwLock::new(TeamsState::from([ ++ ("feco".to_string(), SolvedPuzzles::from(["1".to_string()])), ++ ( ++ "jero".to_string(), ++ SolvedPuzzles::from(["1".to_string(), "2".to_string(), "3".to_string()]), ++ ), ++ ( ++ "karo".to_string(), ++ SolvedPuzzles::from(["1".to_string(), "3".to_string()]), ++ ), ++ ("genyo".to_string(), SolvedPuzzles::from(["".to_string()])), ++ ])) ++}); + + /// without `key`, the app won't run + fn ensure_env_var(key: &str) -> String { diff --git a/src/app.rs b/src/app.rs index 0883de7..361be9e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,15 +1,112 @@ +#![deny(clippy::unwrap_used)] +#![forbid(unsafe_code)] + use dioxus::prelude::*; +pub mod actions; +mod hooks; +mod models; +pub mod utils; + +pub use crate::app::models::AuthState; + +use crate::{ + backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, + components::{ + input_section::InputSection, score_table::ScoreTable, team_section::TeamSection, + toast::ToastProvider, + }, +}; + const FAVICON: Asset = asset!("/assets/favicon.ico"); -const MAIN_CSS: Asset = asset!("/assets/main.css"); -const HEADER_SVG: Asset = asset!("/assets/header.svg"); +// const MAIN_CSS: Asset = asset!("/assets/main.css"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); +const DX_CSS: Asset = asset!("/assets/dx-components-theme.css"); #[component] pub fn App() -> Element { + trace!("kicking off app"); + // State management variables + trace!("initing variables"); + let puzzle_id = use_signal(String::new); + let puzzle_solution = use_signal(String::new); + let puzzle_value = use_signal(String::new); + let auth = use_signal(AuthState::default); + let auth_current = auth.read(); + let teams_state = use_signal(Vec::<(String, SolvedPuzzles)>::new); + let puzzles = use_signal(Vec::<(PuzzleId, PuzzleValue)>::new); + let title = use_signal(|| None::); + let is_fullscreen = use_signal(|| false); + let parsed_puzzles = use_signal(PuzzleSolutions::new); + let logout_alert = use_signal(|| false); + let delete_alert = use_signal(|| false); + + trace!("variables inited"); + + // side effect handlers + hooks::check_auth(auth); + hooks::load_title(title); + hooks::subscribe_stream(teams_state, puzzles); + rsx! { document::Link { rel: "icon", href: FAVICON } - document::Link { rel: "stylesheet", href: MAIN_CSS } document::Link { rel: "stylesheet", href: TAILWIND_CSS } - "hello" + // document::Link { rel: "stylesheet", href: MAIN_CSS } + document::Link { rel: "stylesheet", href: TAILWIND_CSS } + document::Link { rel: "stylesheet", href: DX_CSS } + + ToastProvider { + div { class: if *is_fullscreen.read() { "table-only" } else { "normal" }, + div { class: "others-container", + if let Some(t) = &*title.read() { + h1 { class: "mb-4 font-bold text-lg", + "{t}", + } + } else { + div { class: "loading", + h1 { class: "font-bold text-[clamp(1rem,4vw,2.5rem)]", + "Várakozás az Apollo kiszolgálóra" + } + } + } + + } // div: other-container + + div { class: "table-container mt-5 overflow-x-auto", style: "-webkit-overflow-scrolling: touch;", + ScoreTable { + puzzles: puzzles, + teams_state: teams_state, + toggle_fullscreen: actions::toggle_fullscreen(is_fullscreen), + } + } // div: table-container + + div { class: "others-container mt-5", + if title.read().as_ref().is_some_and(|t| !t.is_empty()) { + // Input section + div { class: "input-section relative input-flexy-boxy flex flex-wrap gap-3 flex-row", + InputSection { + auth, + puzzle_id, + puzzle_value, + puzzle_solution, + parsed_puzzles, + teams_state, + puzzles, + } + } // div: input-section + + if auth_current.joined && !auth_current.is_admin{ + TeamSection { + auth, + logout_alert, + delete_alert, + teams_state, + puzzles, + } + + } + } + } // div: other-container + } // end main div + } } } diff --git a/src/app/actions.rs b/src/app/actions.rs new file mode 100644 index 0000000..7e7936c --- /dev/null +++ b/src/app/actions.rs @@ -0,0 +1,232 @@ +use dioxus::{prelude::*, signals::Signal}; +use dioxus_primitives::toast::Toasts; + +use crate::{ + app::{ + models::AuthState, + utils::{ + parse_puzzle_csv, popup_error, popup_normal, validate_puzzle_id, + validate_puzzle_solution, validate_puzzle_value, + }, + }, + backend::models::{Puzzle, PuzzleSolutions}, +}; + +// TODO could be handled better +fn check_admin_username(username: String) -> bool { + // use std::env; + let admin_username = "admin"; + username == admin_username +} + +pub async fn handle_join(mut auth: Signal, toast_api: Toasts) { + let u = auth.read().username.clone(); + if !auth.read().validate_username(toast_api) { + return; + } + + if check_admin_username(u.clone()) { + auth.write().is_admin = true; + auth.write().show_password_prompt = true; + + // If password is empty, don't proceed yet + if auth.read().validate_password(toast_api) { + let auth_curr = auth.read().clone(); + match crate::backend::endpoints::set_admin_password(auth_curr.password).await { + Ok(msg) => { + auth.write().joined = true; + popup_normal(toast_api, msg); + } + Err(e) => popup_error( + toast_api, + format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), + ), + } + } + return; + }; + + let _ok_none = crate::backend::endpoints::join(u.clone()).await; + match crate::backend::endpoints::auth_state().await { + Ok(uname) => { + popup_normal(toast_api, format!("Üdv, {}", uname)); + auth.write().joined = true; // TODO auth.reset(_somefield) + auth.write().password = String::new(); + auth.write().show_password_prompt = false; + } + Err(e) => { + popup_error( + toast_api, + format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), + ); + } + } +} + +pub async fn handle_user_submit( + mut puzzle_id: Signal, + mut puzzle_solution: Signal, + toast_api: Toasts, +) { + let puzzle_current = puzzle_id.read().clone(); + let solution_current = puzzle_solution.read().clone(); + if !validate_puzzle_id(&puzzle_current, toast_api) { + return; + } + if !validate_puzzle_solution(&solution_current, toast_api) { + return; + } + + match crate::backend::endpoints::submit_solution(puzzle_current, solution_current).await { + Ok(msg) => { + popup_normal(toast_api, msg); + puzzle_id.set(String::new()); + puzzle_solution.set(String::new()); + } + Err(e) => { + popup_error(toast_api, format!("Hiba: {}", e)); + } + } +} + +pub async fn handle_admin_submit( + mut puzzle_id: Signal, + mut puzzle_value: Signal, + mut puzzle_solution: Signal, + parsed_puzzles: Signal, + password_current: String, + toast_api: Toasts, +) { + match crate::backend::endpoints::set_solution( + if parsed_puzzles.read().is_empty() { + if !validate_puzzle_id(&puzzle_id.read().clone(), toast_api) { + return; + } + if !validate_puzzle_solution(&puzzle_solution.read().clone(), toast_api) { + return; + } + if !validate_puzzle_value(&puzzle_value.read().clone(), toast_api) { + return; + } + + debug!("parsed puzzles is empty, trying from manual values"); + let Ok(value_current) = puzzle_value.read().parse() else { + popup_error(toast_api, "Az érték csak szám lehet"); + return; + }; + PuzzleSolutions::from([( + puzzle_id.read().clone(), + Puzzle { + value: value_current, + solution: puzzle_solution.read().clone(), + }, + )]) + } else { + parsed_puzzles.read().clone() + }, + password_current, + ) + .await + { + Ok(msg) => { + popup_normal(toast_api, msg); + puzzle_id.set(String::new()); + puzzle_solution.set(String::new()); + puzzle_value.set(String::new()); + // password.set(String::new()); NOTE should remember password? + } + Err(e) => { + popup_error( + toast_api, + format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), + ); + } + } +} + +pub fn handle_csv( + mut parsed_puzzles: Signal, + toast_api: Toasts, +) -> impl FnMut(Event) + 'static { + move |form_data| { + spawn(async move { + if let Some(file) = form_data.files().first() { + let Ok(text) = file.read_string().await else { + warn!("couldn't parse text from selected file"); + return; + }; + parsed_puzzles.set(parse_puzzle_csv(&text, toast_api)); + debug!("set puzzles from csv"); + } else { + warn!("couldn't read selected file"); + }; + }); + } +} + +pub fn toggle_fullscreen( + mut is_fullscreen: Signal, +) -> impl FnMut(Event) + 'static { + move |_| { + trace!("fullscreen toggle called"); + let fullscreen_current = *is_fullscreen.read(); + is_fullscreen.set(!fullscreen_current); + } +} + +pub fn handle_action( + auth: Signal, + toast_api: Toasts, + puzzle_id: Signal, + puzzle_value: Signal, + puzzle_solution: Signal, + parsed_puzzles: Signal, +) -> impl FnMut(Event) + 'static { + move |_| { + spawn(async move { + trace!("action handler called"); + if !auth.read().joined { + self::handle_join(auth, toast_api).await; + } else if auth.read().is_admin { + self::handle_admin_submit( + puzzle_id, + puzzle_value, + puzzle_solution, + parsed_puzzles, + auth.read().password.clone(), + toast_api, + ) + .await; + } else { + self::handle_user_submit(puzzle_id, puzzle_solution, toast_api).await; + } + }); + } +} + +pub fn handle_logout( + mut auth: Signal, + toast_api: Toasts, + superlogout: bool, +) -> impl FnMut(Event) + 'static { + let wipe = match superlogout { + true => Some(true), + false => None, + }; + move |_| { + spawn(async move { + match crate::backend::endpoints::logout(wipe).await { + Ok(_) => { + popup_normal(toast_api, format!("Viszlát, {}", auth.read().username)); + auth.set(AuthState::default()); + } + Err(e) => { + popup_error( + toast_api, + format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), + ); + } + } + }); + } +} diff --git a/src/app/hooks.rs b/src/app/hooks.rs new file mode 100644 index 0000000..b0c52b6 --- /dev/null +++ b/src/app/hooks.rs @@ -0,0 +1,51 @@ +use dioxus::prelude::*; + +use crate::{ + app::{AuthState, utils::get_points_of}, + backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, +}; + +pub fn load_title(mut title: Signal>) { + use_future(move || async move { + let result = crate::backend::endpoints::event_title() + .await + // .inspect_err(|e| popup_error(message, format!("Hiba: {}", e))) //TODO WARN + .ok(); + + title.set(result.unwrap_or_else(|| "Apollo esemény".into()).into()); + }); +} + +pub fn check_auth(mut auth: Signal) { + use_future(move || async move { + if let Ok(name) = crate::backend::endpoints::auth_state().await { + auth.write().username = name.clone(); + auth.write().joined = true; + // popup_normal(message, format!("Üdv újra, {name}")); //TODO WARN + } + }); +} + +pub fn subscribe_stream( + mut teams_state: Signal>, + mut puzzles: Signal>, +) { + use_future(move || async move { + let mut stream = crate::backend::endpoints::state_stream().await?; // TODO WARN error handling + while let Some(Ok((new_team_state, new_puzzles))) = stream.next().await { + let mut puzzles_sorted: Vec<_> = new_puzzles.into_iter().collect(); + puzzles_sorted.sort_by(|p1, p2| p1.1.cmp(&p2.1).then_with(|| p1.0.cmp(&p2.0))); + + let mut teams_sorted: Vec<_> = new_team_state.into_iter().collect(); + teams_sorted.sort_by(|a, b| { + get_points_of(b, puzzles.read().clone()) + .cmp(&get_points_of(a, puzzles.read().clone())) + .then_with(|| a.0.cmp(&b.0)) + }); + + puzzles.set(puzzles_sorted); + teams_state.set(teams_sorted); + } + dioxus::Ok(()) + }); +} diff --git a/src/app/models.rs b/src/app/models.rs new file mode 100644 index 0000000..e607113 --- /dev/null +++ b/src/app/models.rs @@ -0,0 +1,40 @@ +use dioxus_primitives::toast::Toasts; + +use crate::app::utils::popup_error; + +#[derive(Default, Clone, PartialEq)] +pub struct AuthState { + pub(crate) username: String, + pub(crate) password: String, + pub(crate) joined: bool, + pub(crate) is_admin: bool, + pub(crate) show_password_prompt: bool, +} + +impl AuthState { + pub fn validate_username(&self, toast_api: Toasts) -> bool { + if self.username.is_empty() { + popup_error(toast_api, "A csapatnév nem lehet üres"); + return false; + } + true + } + pub fn validate_password(&self, toast_api: Toasts) -> bool { + if self.is_admin && self.password.is_empty() { + popup_error(toast_api, "A jelszó nem lehet üres"); + return false; + } + true + } + + pub fn validate(&self, toast_api: Toasts) -> bool { + if !self.validate_username(toast_api) { + return false; + }; + if !self.validate_password(toast_api) { + return false; + }; + + true + } +} diff --git a/src/app/utils.rs b/src/app/utils.rs new file mode 100644 index 0000000..48525ad --- /dev/null +++ b/src/app/utils.rs @@ -0,0 +1,122 @@ +use std::time::Duration; + +use csv::ReaderBuilder; +use dioxus_primitives::toast::{ToastOptions, Toasts}; + +use crate::backend::models::{Puzzle, PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}; + +pub fn parse_puzzle_csv(csv_text: &str, toast_api: Toasts) -> PuzzleSolutions { + let mut rdr = ReaderBuilder::new() + .has_headers(true) + .from_reader(csv_text.as_bytes()); + + let mut puzzles = PuzzleSolutions::new(); + let mut volte = false; + + for result in rdr.records() { + let record = match result { + Ok(r) => r, + Err(_e) => { + // warn!("skipping invalid CSV row: {}", e); + volte = true; + continue; + } + }; + let Some(id) = record.get(0) else { + // warn!("invalid 'id' field in CSV row: {:?}", &record); // TODO dont log value ever + volte = true; + continue; + }; + let Some(solution) = record.get(1) else { + // warn!("invalid 'solution' field in CSV row: {:?}", &record); + volte = true; + continue; + }; + let Some(value) = record.get(2) else { + // warn!("invalid 'value' field in CSV row: {:?}", &record); + volte = true; + continue; + }; + let Ok(value_num) = value.parse::() else { + // warn!( + // "value of field 'value' is not a number in CSV row: {:?}", + // &record + // ); + volte = true; + continue; + }; + + puzzles.insert( + id.into(), + Puzzle { + solution: solution.into(), + value: value_num, + }, + ); + } + + if volte { + popup_error( + toast_api, + "néhány sort nem sikerült betölteni, nézd meg a konzolt", + ); + } + + puzzles +} + +pub fn popup_error(toast_api: Toasts, text: impl std::fmt::Display) { + toast_api.error( + "".to_string(), + ToastOptions::new() + .description(text) + .duration(Duration::from_secs(3)) + .permanent(false), + ); +} + +pub fn popup_normal(toast_api: Toasts, text: impl std::fmt::Display) { + toast_api.info( + "".to_string(), + ToastOptions::new() + .description(text) + .duration(Duration::from_secs(3)) + .permanent(false), + ); +} + +pub fn get_points_of(team: &(String, SolvedPuzzles), puzzles: Vec<(PuzzleId, PuzzleValue)>) -> u32 { + puzzles + .iter() + .filter(|(id, _)| team.1.contains(id)) + .map(|(_, value)| *value) + .sum() +} + +pub fn validate_puzzle_id(puzzle_id: &str, toast_api: Toasts) -> bool { + match !puzzle_id.is_empty() { + true => true, + false => { + popup_error(toast_api, "a feladat nem lehet üres"); + false + } + } +} +pub fn validate_puzzle_solution(puzzle_solution: &str, toast_api: Toasts) -> bool { + match !puzzle_solution.is_empty() { + true => true, + false => { + popup_error(toast_api, "a megoldás nem lehet üres"); + false + } + } +} +pub fn validate_puzzle_value(puzzle_value: &str, toast_api: Toasts) -> bool { + match !puzzle_value.is_empty() { + true => true, + false => { + popup_error(toast_api, "az érték nem lehet üres"); + false + } + } +} diff --git a/src/components/alert_dialog/component.rs b/src/components/alert_dialog/component.rs new file mode 100644 index 0000000..2cd3c2b --- /dev/null +++ b/src/components/alert_dialog/component.rs @@ -0,0 +1,75 @@ +use dioxus::prelude::*; +use dioxus_primitives::alert_dialog::{ + self, AlertDialogActionProps, AlertDialogActionsProps, AlertDialogCancelProps, + AlertDialogContentProps, AlertDialogDescriptionProps, AlertDialogRootProps, + AlertDialogTitleProps, +}; + +#[component] +pub fn AlertDialogRoot(props: AlertDialogRootProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + alert_dialog::AlertDialogRoot { + class: "alert-dialog-backdrop", + id: props.id, + default_open: props.default_open, + open: props.open, + on_open_change: props.on_open_change, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn AlertDialogContent(props: AlertDialogContentProps) -> Element { + rsx! { + alert_dialog::AlertDialogContent { + id: props.id, + class: props.class.unwrap_or_default() + " alert-dialog", + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn AlertDialogTitle(props: AlertDialogTitleProps) -> Element { + alert_dialog::AlertDialogTitle(props) +} + +#[component] +pub fn AlertDialogDescription(props: AlertDialogDescriptionProps) -> Element { + alert_dialog::AlertDialogDescription(props) +} + +#[component] +pub fn AlertDialogActions(props: AlertDialogActionsProps) -> Element { + rsx! { + alert_dialog::AlertDialogActions { class: "alert-dialog-actions", attributes: props.attributes, {props.children} } + } +} + +#[component] +pub fn AlertDialogCancel(props: AlertDialogCancelProps) -> Element { + rsx! { + alert_dialog::AlertDialogCancel { + on_click: props.on_click, + class: "alert-dialog-cancel", + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn AlertDialogAction(props: AlertDialogActionProps) -> Element { + rsx! { + alert_dialog::AlertDialogAction { + class: "alert-dialog-action", + on_click: props.on_click, + attributes: props.attributes, + {props.children} + } + } +} diff --git a/src/components/alert_dialog/mod.rs b/src/components/alert_dialog/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/src/components/alert_dialog/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/src/components/alert_dialog/style.css b/src/components/alert_dialog/style.css new file mode 100644 index 0000000..254d74f --- /dev/null +++ b/src/components/alert_dialog/style.css @@ -0,0 +1,135 @@ +/* Alert Dialog Backdrop */ +.alert-dialog-backdrop { + position: fixed; + z-index: 1000; + background: rgb(0 0 0 / 30%); + inset: 0; +} + +.alert-dialog-backdrop[data-state="closed"] { + animation: alert-animate-out 150ms ease-in forwards; +} + +@keyframes alert-animate-out { + 0% { + opacity: 1; + transform: scale(1) translateY(0); + } + + 100% { + opacity: 0; + transform: scale(0.95) translateY(-2px); + } +} + +.alert-dialog-backdrop[data-state="open"] { + animation: alert-animate-in 150ms ease-out forwards; +} + +@keyframes alert-animate-in { + 0% { + opacity: 0; + transform: scale(0.95) translateY(-2px); + } + + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Alert Dialog Container - improved for theme consistency */ +.alert-dialog { + position: fixed; + z-index: 1001; + top: 50%; + left: 50%; + display: flex; + width: 100%; + max-width: calc(100% - 2rem); + box-sizing: border-box; + flex-direction: column; + padding: 32px 24px 24px; + border: 1px solid darkred; + border-radius: 8px; + margin: 0; + animation: none; + background: #0f1116; + box-shadow: 0 2px 10px rgb(0 0 0 / 18%); + /* color: var(--secondary-color-4); */ + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, + Arial, sans-serif; + gap: 16px; + text-align: center; + transform: translate(-50%, -50%); +} + +.alert-dialog-title { + margin: 0; + color: var(--secondary-color-4); + font-size: 1.25rem; + font-weight: 700; +} + +.alert-dialog-description { + margin: 0; + color: var(--secondary-color-5); + font-size: 1rem; +} + +.alert-dialog-actions { + display: flex; + flex-direction: column-reverse; + gap: 12px; +} + +@media (width >= 40rem) { + .alert-dialog-actions { + flex-direction: row; + justify-content: flex-end; + } + + .alert-dialog { + max-width: 32rem; + text-align: left; + } +} + +.alert-dialog-cancel { + padding: 8px 18px; + border: 1px solid var(--primary-color-6); + border-radius: 0.5rem; + background-color: var(--light, var(--primary-color)) + var(--dark, var(--primary-color-3)); + color: var(--secondary-color-4); + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s ease; +} + +.alert-dialog-cancel:hover { + background-color: var(--primary-color-4); +} + +.alert-dialog-cancel:focus-visible { + box-shadow: 0 0 0 2px var(--focused-border-color); +} + +.alert-dialog-action { + padding: 8px 18px; + /* border: 1px solid var(--primary-error-color); */ + border-radius: 0.5rem; + background-color: darkred; + /* color: var(--contrast-error-color); */ + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s ease; +} + +.alert-dialog-action:hover { + background-color: #450000; +} + +.alert-dialog-action:focus-visible { + box-shadow: 0 0 0 2px var(--focused-border-color); +} diff --git a/src/components/input_section.rs b/src/components/input_section.rs new file mode 100644 index 0000000..2b1b51a --- /dev/null +++ b/src/components/input_section.rs @@ -0,0 +1,124 @@ +use dioxus::prelude::*; +use dioxus_primitives::toast::use_toast; + +use crate::{ + app::{AuthState, actions}, + backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, + components::tailwind_constants::{BUTTON, CSV_INPUT, FLASH, INPUT}, +}; + +#[component] +pub fn InputSection( + auth: Signal, + puzzle_id: Signal, + puzzle_value: Signal, + puzzle_solution: Signal, + parsed_puzzles: Signal, + mut teams_state: Signal>, + puzzles: Signal>, +) -> Element { + let auth_current = auth.read().clone(); + let teams = teams_state.read(); + let ref_puzzles = puzzles.read(); + let toast_api = use_toast(); + + let solved = teams + .iter() + .find(|(team, _)| team == &auth_current.username) + .map(|(_, solved)| solved); + + let selectopts = solved + .into_iter() + .flat_map(|solved| ref_puzzles.iter().filter(|(id, _)| !solved.contains(id))); + + rsx!( + if !auth_current.joined { + // Join form + input { class: INPUT, + r#type: "text", + placeholder: "Csapatnév", + value: "{auth_current.username}", + cursor: "text", + oninput: move |evt| auth.write().username = evt.value() + } + + if auth_current.show_password_prompt { + input { class: "{INPUT}", + r#type: "password", + placeholder: "Admin jelszó", + value: "{auth_current.password}", + cursor: "text", + oninput: move |evt| auth.write().password = evt.value() + } + } + + button { class: "{BUTTON} {FLASH}", cursor: "pointer", onclick: actions::handle_action(auth, toast_api, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Belépés" } + } else { + // Submit form + if !auth_current.is_admin { + select { + class: "{INPUT}", + cursor: "pointer", + onchange: move |evt: Event| { + debug!("{}", evt.value()); + puzzle_id.set(evt.value()); + }, + if puzzle_id.is_empty() { + option { disabled: true, selected: true, "Feladat kiválasztása" } + } + for (id, _) in selectopts { + option { + cursor: "pointer", + value: "{id}", + "{id}" + } + } + } + } else { + input { class: "{INPUT}", + r#type: "text", + placeholder: "Feladat", + value: "{puzzle_id}", + cursor: "text", + oninput: move |evt| puzzle_id.set(evt.value()) + } + } + + input { class: "{INPUT}", + r#type: "text", + placeholder: "Megoldás", + value: "{puzzle_solution}", + cursor: "text", + oninput: move |evt| puzzle_solution.set(evt.value()) + } + + if auth_current.is_admin { + input { class: "{INPUT}", + r#type: "text", + placeholder: "Érték/Nyeremény", + value: "{puzzle_value}", + cursor: "text", + oninput: move |evt| puzzle_value.set(evt.value()) + } + + input { class: "{INPUT}", + r#type: "password", + placeholder: "Admin jelszó", + value: "{auth_current.password}", + cursor: "text", + oninput: move |evt| auth.write().password = evt.value() + } + + input { class: "{CSV_INPUT}", + r#type: "file", + r#accept: ".csv", + cursor: "pointer", + onchange: actions::handle_csv(parsed_puzzles, toast_api), + } + + button { class: "{BUTTON} {FLASH}", cursor: "pointer", onclick: actions::handle_action(auth, toast_api, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Beállítás" } + } else { + button { class: "{BUTTON} {FLASH}", cursor: "pointer", onclick: actions::handle_action(auth, toast_api, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Küldés" } + } + }) +} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..dd54395 --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,8 @@ +// AUTOGENERTED Components module +pub mod alert_dialog; +pub mod input_section; +pub mod score_table; +pub mod tailwind_constants; +pub mod team_section; +pub mod team_status; +pub mod toast; diff --git a/src/components/score_table.rs b/src/components/score_table.rs new file mode 100644 index 0000000..8d7145a --- /dev/null +++ b/src/components/score_table.rs @@ -0,0 +1,67 @@ +use dioxus::prelude::*; +// use dioxus_primitives::{ContentAlign, ContentSide}; + +use crate::{ + backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, + // components::tooltip::*, +}; + +#[component] +pub fn ScoreTable( + puzzles: Signal>, + teams_state: Signal>, + toggle_fullscreen: EventHandler, +) -> Element { + rsx! { + table { class: "", + onclick: toggle_fullscreen, + cursor: "pointer", + thead { + tr { + if !puzzles.read().is_empty() || !teams_state.read().is_empty() { + th { class: "text-left h-[70px] pl-2", "." } + for (id, value) in puzzles.read().iter() { + th { class: "h-[70px]", + span { class: "text-md", + "{id}" + } + br { } + span { class: "text-sm", + "({value} pont)" + } + + // Tooltip { + // TooltipTrigger { class: "text-(--light)", "{id}" } + + // TooltipContent { + // side: ContentSide::Top, + // align: ContentAlign::Center, + // div { class: "p-2 border border-(--dark2) rounded-md bg-(--dark)", + // "value: {value}" + // } + // } + // } + } + } + } + } + } + tbody { + for (team_name, solved) in teams_state.read().iter() { + tr { + td { class: "text-left pl-2 text-(--light) bg-(--dark2)", "{team_name}" } + for (puzzle_id, _puzzle) in puzzles.read().iter() { + td { class: "text-(--light) bg-(--dark) text-center text-[30px] font-[900]", + if solved.contains(puzzle_id) { + "X" + } else { + "" + } + } + } + } + } + } + } + } +} diff --git a/src/components/tailwind_constants.rs b/src/components/tailwind_constants.rs new file mode 100644 index 0000000..c27f1a4 --- /dev/null +++ b/src/components/tailwind_constants.rs @@ -0,0 +1,13 @@ +pub const BUTTON: &str = "w-[150px] h-[50px] px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) hover:bg-[#1b5b76] text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; +pub const BUTTON_RED: &str = + "h-[35px] px-3 py-2 rounded-lg bg-[darkred] hover:bg-[#690000] text-white transition"; +pub const INPUT: &str = "w-[250px] h-[50px] px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; +pub const CSV_INPUT: &str = "w-70 px-3 py-2 rounded-lg border border-gray-300 bg-gray-100 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; +pub const FLASH: &str = "relative overflow-hidden + before:pointer-events-none before:absolute + before:top-[-50%] before:left-[-75%] + before:h-[200%] before:w-1/2 + before:rotate-[25deg] + before:bg-gradient-to-r before:from-transparent before:via-white/40 before:to-transparent + before:transition-all before:duration-350 before:ease-out + hover:before:left-[125%]"; diff --git a/src/components/team_section.rs b/src/components/team_section.rs new file mode 100644 index 0000000..656ad1e --- /dev/null +++ b/src/components/team_section.rs @@ -0,0 +1,81 @@ +use dioxus::prelude::*; +use dioxus_primitives::toast::use_toast; + +use crate::app::{AuthState, actions::handle_logout, utils::get_points_of}; +use crate::backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}; +use crate::components::{ + alert_dialog::*, tailwind_constants::BUTTON_RED, tailwind_constants::FLASH, + team_status::TeamStatus, +}; + +#[component] +pub fn TeamSection( + auth: Signal, + mut logout_alert: Signal, + mut delete_alert: Signal, + mut teams_state: Signal>, + puzzles: Signal>, +) -> Element { + let auth_current = auth.read().clone(); + let toast_api = use_toast(); + let points = teams_state + .read() + .iter() + .find(|(team, _)| team == &auth_current.username) + .map(|team| get_points_of(team, puzzles.read().clone())) + .unwrap_or(0); + + rsx! { + div { class: "mt-5", + TeamStatus { + team: auth_current.username.clone(), + points: points, + } + } + div { class: "mt-10", + button { class: "{BUTTON_RED} {FLASH}", + onclick: move |_| logout_alert.set(true), + cursor: "pointer", + "Kijelentkezés" + } + + AlertDialogRoot { open: logout_alert(), on_open_change: move |v| logout_alert.set(v), + AlertDialogContent { + AlertDialogTitle { "Delete item" } + AlertDialogDescription { + "Biztosan ki szeretnél lépni?" + br { } + "(Később visszaléphetsz, eddigi progressziód megmarad)" + } + AlertDialogActions { + AlertDialogCancel { "Mégsem" } + AlertDialogAction { on_click: handle_logout(auth, toast_api, false), "Kilépés" } + } + } + } + } + div { class: "mt-2", + button { class: "{BUTTON_RED} {FLASH}", + onclick: move |_| delete_alert.set(true), + cursor: "pointer", + "Csapat törlése" + } + + AlertDialogRoot { open: delete_alert(), on_open_change: move |v| delete_alert.set(v), + AlertDialogContent { + AlertDialogTitle { "Delete item" } + AlertDialogDescription { + "Ez a funkció a csapat minden adatát ", + strong { "véglegesen törli." } + br {} + "Biztosan folytatod?" + } + AlertDialogActions { + AlertDialogCancel { "Mégsem" } + AlertDialogAction { on_click: handle_logout(auth, toast_api, true), "Csapat Törlése" } + } + } + } + } + } +} diff --git a/src/components/team_status.rs b/src/components/team_status.rs new file mode 100644 index 0000000..c12ff5e --- /dev/null +++ b/src/components/team_status.rs @@ -0,0 +1,14 @@ +use dioxus::prelude::*; + +#[component] +pub fn TeamStatus(team: String, points: u32) -> Element { + rsx!( + span { + "csapat: {team}" + } + br { } + span { + "pontok: {points}" + } + ) +} diff --git a/src/components/toast/component.rs b/src/components/toast/component.rs new file mode 100644 index 0000000..4b6878f --- /dev/null +++ b/src/components/toast/component.rs @@ -0,0 +1,15 @@ +use dioxus::prelude::*; +use dioxus_primitives::toast::{self, ToastProviderProps}; + +#[component] +pub fn ToastProvider(props: ToastProviderProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + toast::ToastProvider { + default_duration: props.default_duration, + max_toasts: props.max_toasts, + render_toast: props.render_toast, + {props.children} + } + } +} diff --git a/src/components/toast/mod.rs b/src/components/toast/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/src/components/toast/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/src/components/toast/style.css b/src/components/toast/style.css new file mode 100644 index 0000000..7bf994a --- /dev/null +++ b/src/components/toast/style.css @@ -0,0 +1,185 @@ +.toast-container { + position: fixed; + z-index: 9999; + right: 20px; + bottom: 20px; + max-width: 350px; +} + +.toast-list { + display: flex; + flex-direction: column-reverse; + padding: 0; + margin: 0; + gap: 0.75rem; +} + +.toast-item { + display: flex; +} + +.toast { + z-index: calc(var(--toast-count) - var(--toast-index)); + display: flex; + overflow: hidden; + width: 18rem; + height: 4rem; + box-sizing: border-box; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border: 1px solid var(--light, var(--primary-color-6)) + var(--dark, var(--primary-color-7)); + border-radius: 0.5rem; + margin-top: -4rem; + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); + filter: var(--light, none) + var( + --dark, + brightness(calc(0.5 + 0.5 * (1 - ((var(--toast-index) + 1) / 4)))) + ); + opacity: calc(1 - var(--toast-hidden)); + transform: scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + transition: transform 0.2s ease, margin-top 0.2s ease, opacity 0.2s ease; + + --toast-hidden: calc(min(max(0, var(--toast-index) - 2), 1)); +} + +.toast-container:not(:hover, :focus-within) + .toast[data-toast-even]:not([data-top]) { + animation: slide-up-even 0.2s ease-out; +} + +.toast-container:not(:hover, :focus-within) + .toast[data-toast-odd]:not([data-top]) { + animation: slide-up-odd 0.2s ease-out; +} + +@keyframes slide-up-even { + from { + transform: translateY(0.5rem) + scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + } + + to { + transform: translateY(0) + scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + } +} + +@keyframes slide-up-odd { + from { + transform: translateY(0.5rem) + scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + } + + to { + transform: translateY(0) + scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + } +} + +.toast[data-top] { + animation: slide-in 0.2s ease-out; +} + +.toast-container:hover .toast[data-top], +.toast-container:focus-within .toast[data-top] { + animation: slide-in 0 ease-out; +} + +@keyframes slide-in { + from { + opacity: 0; + transform: translateY(100%) + scale( + calc(110% - var(--toast-index) * 5%), + calc(110% - var(--toast-index) * 2%) + ); + } + + to { + opacity: 1; + transform: translateY(0) + scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + } +} + +.toast-container:hover .toast, +.toast-container:focus-within .toast { + margin-top: var(--toast-padding); + filter: brightness(1); + opacity: 1; + transform: scale(calc(100%)); +} + +.toast[data-type="success"] { + background-color: var(--primary-success-color); + color: var(--secondary-success-color); +} + +.toast[data-type="error"] { + background-color: var(--primary-error-color); + color: var(--contrast-error-color); +} + +.toast[data-type="warning"] { + background-color: var(--primary-warning-color); + color: var(--secondary-warning-color); +} + +.toast[data-type="info"] { + background-color: var(--primary-info-color); + color: var(--secondary-info-color); +} + +.toast-content { + flex: 1; + margin-right: 8px; + transition: filter 0.2s ease; +} + +.toast-title { + margin-bottom: 4px; + color: var(--secondary-color-4); + font-weight: 600; +} + +.toast-description { + color: var(--secondary-color-3); + font-size: 0.875rem; +} + +.toast-close { + align-self: flex-start; + padding: 0; + border: none; + margin: 0; + background: none; + color: var(--secondary-color-3); + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.toast-close:hover { + color: var(--secondary-color-1); +} diff --git a/src/main.rs b/src/main.rs index cf80920..300a3dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod backend; +mod components; fn main() { dioxus::logger::initialize_default(); diff --git a/tailwind.css b/tailwind.css index f1d8c73..2ee1825 100644 --- a/tailwind.css +++ b/tailwind.css @@ -1 +1,12 @@ @import "tailwindcss"; +@import "./assets/main.css"; + +@theme { + --text-lg: 3rem; + --bg: #0f1116; + --dark1: #13293d; + --dark2: #006494; + --middle: #247ba0; + --light2: #1b98e0; + --light1: #e8f1f2; +}