From 7106b2540beb7a2e81c2fb07a51b8b14a5e13ed1 Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 2 Dec 2025 10:23:36 +0100 Subject: [PATCH 01/70] feat(client): add a very basic WIP client --- assets/main.css | 25 +++--- assets/tailwind.css | 39 ++++++++ src/app.rs | 210 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 261 insertions(+), 13 deletions(-) diff --git a/assets/main.css b/assets/main.css index 4314613..865553a 100644 --- a/assets/main.css +++ b/assets/main.css @@ -1,8 +1,12 @@ /* App-wide styling */ +* { + border: solid 1px blue; +} + body { background-color: #0f1116; color: #ffffff; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; margin: 20px; } @@ -46,8 +50,8 @@ body { #navbar { display: flex; flex-direction: row; - } - +} + #navbar a { color: #ffffff; margin-right: 20px; @@ -63,8 +67,8 @@ body { /* Blog page */ #blog { margin-top: 50px; - } - +} + #blog a { color: #ffffff; margin-top: 50px; @@ -81,12 +85,11 @@ body { border-radius: 10px; } -#echo>h4 { +#echo > h4 { margin: 0px 0px 15px 0px; } - -#echo>input { +#echo > input { border: none; border-bottom: 1px white solid; background-color: transparent; @@ -98,10 +101,10 @@ body { width: 100%; } -#echo>input:focus { +#echo > input:focus { border-bottom-color: #6d85c6; } -#echo>p { +#echo > p { margin: 20px 0px 0px auto; -} \ No newline at end of file +} diff --git a/assets/tailwind.css b/assets/tailwind.css index 67b119e..bd7cbaa 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -7,6 +7,8 @@ 'Noto Color Emoji'; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); } @@ -184,9 +186,34 @@ max-width: 96rem; } } + .table { + display: table; + } + .border-collapse { + border-collapse: collapse; + } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } + .resize { + resize: both; + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .underline { + text-decoration-line: underline; + } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } } @property --tw-rotate-x { syntax: "*"; @@ -208,6 +235,16 @@ syntax: "*"; inherits: false; } +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { @@ -216,6 +253,8 @@ --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; + --tw-border-style: solid; + --tw-outline-style: solid; } } } diff --git a/src/app.rs b/src/app.rs index 5962849..441cd07 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,15 +1,221 @@ use dioxus::prelude::*; +use std::collections::{BTreeSet, HashMap}; + +use crate::backend::{PuzzleSolutions, TeamsState}; const FAVICON: Asset = asset!("/assets/favicon.ico"); const MAIN_CSS: Asset = asset!("/assets/main.css"); const HEADER_SVG: Asset = asset!("/assets/header.svg"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); +// Server function to check if username is admin (only one we need to add) +async fn check_admin_username(username: String) -> Result { + use std::env; + let admin_username = "jani"; + Ok(username == admin_username) +} + #[component] pub fn App() -> Element { + // State management + let mut username = use_signal(|| String::new()); + let mut password = use_signal(|| String::new()); + let mut puzzle_id = use_signal(|| String::new()); + let mut solution = use_signal(|| String::new()); + let mut joined = use_signal(|| false); + let mut is_admin = use_signal(|| false); + let mut show_password_prompt = use_signal(|| false); + let mut teams_state = use_signal(|| TeamsState::new()); + let mut puzzles = use_signal(|| BTreeSet::new()); + let mut message = use_signal(|| String::new()); + + use_future(move || async move { + // Call the SSE endpoint to get a stream of events + let mut stream = crate::backend::state_stream().await?; + + // And then poll it for new events, adding them to our signal + while let Some(Ok(data)) = stream.next().await { + teams_state.set(data.0); + puzzles.set(data.1); + } + + dioxus::Ok(()) + }); + + // Handle join/submit button click + let handle_action = move |_| { + spawn(async move { + let username_val = username.read().clone(); + let password_val = password.read().clone(); + let is_joined = *joined.read(); + let admin = *is_admin.read(); + + if !is_joined { + // Check if username is admin before joining + if let Ok(is_admin_user) = check_admin_username(username_val.clone()).await { + if is_admin_user { + is_admin.set(true); + show_password_prompt.set(true); + + // If password is empty, don't proceed yet + if password_val.is_empty() { + message.set("Please enter admin password".to_string()); + return; + } + } + } + + // Join team - call backend function directly + let pwd = if admin || *show_password_prompt.read() { + Some(password_val.clone()) + } else { + None + }; + + match crate::backend::join(username_val.clone(), pwd).await { + Ok(msg) => { + message.set(msg); + joined.set(true); + password.set(String::new()); + show_password_prompt.set(false); + } + Err(e) => { + message.set(format!("Error: {}", e)); + } + } + } else { + // Submit solution - call backend function directly + let puzzle_val = puzzle_id.read().clone(); + let solution_val = solution.read().clone(); + + if let Ok(puzzle_num) = puzzle_val.parse::() { + if let Ok(solution_num) = solution_val.parse::() { + let pwd = if admin { + Some(password_val.clone()) + } else { + None + }; + + match crate::backend::submit_solution( + username_val.clone(), + puzzle_num, + solution_num, + pwd, + ) + .await + { + Ok(msg) => { + message.set(msg); + puzzle_id.set(String::new()); + solution.set(String::new()); + password.set(String::new()); + } + Err(e) => { + message.set(format!("Error: {}", e)); + } + } + } else { + message.set("Invalid solution number".to_string()); + } + } else { + message.set("Invalid puzzle ID".to_string()); + } + } + }); + }; + rsx! { document::Link { rel: "icon", href: FAVICON } - document::Link { rel: "stylesheet", href: MAIN_CSS } document::Link { rel: "stylesheet", href: TAILWIND_CSS } - "helo" + document::Link { rel: "stylesheet", href: MAIN_CSS } + document::Link { rel: "stylesheet", href: TAILWIND_CSS } + + div { class: "container", + h1 { "Apollo Hackathon Tracker" } + + // Input section + div { class: "input-section", + if !*joined.read() { + // Join form + input { + r#type: "text", + placeholder: "Username", + value: "{username}", + oninput: move |evt| username.set(evt.value()) + } + + if *show_password_prompt.read() { + input { + r#type: "password", + placeholder: "Admin Password", + value: "{password}", + oninput: move |evt| password.set(evt.value()) + } + } + + button { onclick: handle_action, "Join" } + } else { + // Submit form + input { + r#type: "text", + placeholder: "Puzzle ID", + value: "{puzzle_id}", + oninput: move |evt| puzzle_id.set(evt.value()) + } + + input { + r#type: "text", + placeholder: "Solution", + value: "{solution}", + oninput: move |evt| solution.set(evt.value()) + } + + if *is_admin.read() { + input { + r#type: "password", + placeholder: "Admin Password", + value: "{password}", + oninput: move |evt| password.set(evt.value()) + } + } + + button { onclick: handle_action, "Send" } + } + } + + // Message display + if !message.read().is_empty() { + div { class: "message", "{message}" } + } + + // Teams and puzzles table + div { class: "table-container", + table { + thead { + tr { + th { "Team" } + for puzzle in puzzles.read().iter() { + th { "Puzzle {puzzle}" } + } + } + } + tbody { + for (team_name, solved) in teams_state.read().iter() { + tr { + td { "{team_name}" } + for puzzle in puzzles.read().iter() { + td { + if solved.contains(puzzle) { + "X" + } else { + "" + } + } + } + } + } + } + } + } + } } } From 4e816eb17492bd0ae7aab1706bd0590ced1e0682 Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 2 Dec 2025 11:09:05 +0100 Subject: [PATCH 02/70] fix(client): admin doesnt need to join --- src/app.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index 441cd07..11e5904 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,7 @@ const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); // Server function to check if username is admin (only one we need to add) async fn check_admin_username(username: String) -> Result { - use std::env; + // use std::env; let admin_username = "jani"; Ok(username == admin_username) } @@ -62,6 +62,8 @@ pub fn App() -> Element { message.set("Please enter admin password".to_string()); return; } + joined.set(true); + return; } } @@ -72,7 +74,7 @@ pub fn App() -> Element { None }; - match crate::backend::join(username_val.clone(), pwd).await { + match crate::backend::join(username_val.clone()).await { Ok(msg) => { message.set(msg); joined.set(true); From a7b7fc93219c26669575797e25d57192cc2e31f1 Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 3 Dec 2025 11:29:45 +0100 Subject: [PATCH 03/70] feat(client): refactor main.css, add colorscheme in tailwind.css --- assets/main.css | 113 +++++++----------------------------------------- tailwind.css | 9 ++++ 2 files changed, 25 insertions(+), 97 deletions(-) diff --git a/assets/main.css b/assets/main.css index 865553a..5f58108 100644 --- a/assets/main.css +++ b/assets/main.css @@ -1,7 +1,6 @@ -/* App-wide styling */ -* { - border: solid 1px blue; -} +/* +colors +*/ body { background-color: #0f1116; @@ -10,101 +9,21 @@ body { margin: 20px; } -#hero { - margin: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -#links { - width: 400px; - text-align: left; - font-size: x-large; - color: white; - display: flex; - flex-direction: column; -} - -#links a { - color: white; - text-decoration: none; - margin-top: 20px; - margin: 10px 0px; - border: white 1px solid; - border-radius: 5px; - padding: 10px; -} - -#links a:hover { - background-color: #1f1f1f; - cursor: pointer; -} - -#header { - max-width: 1200px; -} - -/* Navbar */ -#navbar { - display: flex; - flex-direction: row; -} - -#navbar a { - color: #ffffff; - margin-right: 20px; - text-decoration: none; - transition: color 0.2s ease; -} - -#navbar a:hover { - cursor: pointer; - color: #91a4d2; -} - -/* Blog page */ -#blog { - margin-top: 50px; -} - -#blog a { - color: #ffffff; - margin-top: 50px; -} - -/* Echo */ -#echo { - width: 360px; - margin-left: auto; - margin-right: auto; - margin-top: 50px; - background-color: #1e222d; - padding: 20px; - border-radius: 10px; -} - -#echo > h4 { - margin: 0px 0px 15px 0px; -} - -#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; } -#echo > input:focus { - border-bottom-color: #6d85c6; +th { + height: 40px; } -#echo > p { - margin: 20px 0px 0px auto; +td { + min-width: 10rem; + height: 50px; + /*text-align: center;*/ } diff --git a/tailwind.css b/tailwind.css index f1d8c73..ee4ecfe 100644 --- a/tailwind.css +++ b/tailwind.css @@ -1 +1,10 @@ @import "tailwindcss"; + +@theme { + --text-lg: 3rem; + --dark: #13293d; + --dark2: #006494; + --middle: #247ba0; + --light2: #1b98e0; + --light: #e8f1f2; +} From 454c8d506e5e87c0f0bc1dec27fc005cb55fb6f0 Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 3 Dec 2025 11:37:31 +0100 Subject: [PATCH 04/70] feat(client): styles for buttons and inputs --- src/app.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index 11e5904..f503a89 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,6 +7,8 @@ const FAVICON: Asset = asset!("/assets/favicon.ico"); const MAIN_CSS: Asset = asset!("/assets/main.css"); const HEADER_SVG: Asset = asset!("/assets/header.svg"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); +const BUTTON: &str = "ml-4 w-30 px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; +const INPUT: &str = "w-50 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"; // Server function to check if username is admin (only one we need to add) async fn check_admin_username(username: String) -> Result { @@ -138,7 +140,7 @@ pub fn App() -> Element { div { class: "input-section", if !*joined.read() { // Join form - input { + input { class: INPUT, r#type: "text", placeholder: "Username", value: "{username}", @@ -146,7 +148,7 @@ pub fn App() -> Element { } if *show_password_prompt.read() { - input { + input { class: "ml-4 {INPUT}", r#type: "password", placeholder: "Admin Password", value: "{password}", @@ -154,17 +156,17 @@ pub fn App() -> Element { } } - button { onclick: handle_action, "Join" } + button { class: BUTTON, onclick: handle_action, "Join" } } else { // Submit form - input { + input { class: INPUT, r#type: "text", placeholder: "Puzzle ID", value: "{puzzle_id}", oninput: move |evt| puzzle_id.set(evt.value()) } - input { + input { class: "ml-4 {INPUT}", r#type: "text", placeholder: "Solution", value: "{solution}", @@ -172,7 +174,7 @@ pub fn App() -> Element { } if *is_admin.read() { - input { + input { class: "ml-4 {INPUT}", r#type: "password", placeholder: "Admin Password", value: "{password}", @@ -180,7 +182,7 @@ pub fn App() -> Element { } } - button { onclick: handle_action, "Send" } + button { class: BUTTON, onclick: handle_action, "Send" } } } From 6703b74b64e4c6da33081493584aed705ae95e5a Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 3 Dec 2025 11:40:35 +0100 Subject: [PATCH 05/70] feat(client): styles for the table --- src/app.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index f503a89..340a869 100644 --- a/src/app.rs +++ b/src/app.rs @@ -193,10 +193,10 @@ pub fn App() -> Element { // Teams and puzzles table div { class: "table-container", - table { + table { class: "mt-5", thead { tr { - th { "Team" } + th { class: "text-left pl-2", "." } for puzzle in puzzles.read().iter() { th { "Puzzle {puzzle}" } } @@ -205,9 +205,9 @@ pub fn App() -> Element { tbody { for (team_name, solved) in teams_state.read().iter() { tr { - td { "{team_name}" } + td { class: "text-left pl-2 bg-(--dark2)", "{team_name}" } for puzzle in puzzles.read().iter() { - td { + td { class: "bg-(--dark) text-center", if solved.contains(puzzle) { "X" } else { From 16d9fdc8e2c46a0a703af21f8d26f54c3409269f Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 3 Dec 2025 12:06:40 +0100 Subject: [PATCH 06/70] fix(client): adjust for updated backend --- src/app.rs | 68 +++++++++++++++++++++++++----------------------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/src/app.rs b/src/app.rs index 340a869..69fc494 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,8 @@ use dioxus::prelude::*; -use std::collections::{BTreeSet, HashMap}; -use crate::backend::{PuzzleSolutions, TeamsState}; +use crate::{ + backend::{PuzzlesExisting, TeamsState}, +}; const FAVICON: Asset = asset!("/assets/favicon.ico"); const MAIN_CSS: Asset = asset!("/assets/main.css"); @@ -28,7 +29,7 @@ pub fn App() -> Element { let mut is_admin = use_signal(|| false); let mut show_password_prompt = use_signal(|| false); let mut teams_state = use_signal(|| TeamsState::new()); - let mut puzzles = use_signal(|| BTreeSet::new()); + let mut puzzles = use_signal(|| PuzzlesExisting::new()); let mut message = use_signal(|| String::new()); use_future(move || async move { @@ -91,38 +92,31 @@ pub fn App() -> Element { // Submit solution - call backend function directly let puzzle_val = puzzle_id.read().clone(); let solution_val = solution.read().clone(); + // let value_current = puzzle_value_FROMUI.read().clone(); + let pwd = if admin { + Some(password_val.clone()) + } else { + None + }; - if let Ok(puzzle_num) = puzzle_val.parse::() { - if let Ok(solution_num) = solution_val.parse::() { - let pwd = if admin { - Some(password_val.clone()) - } else { - None - }; - - match crate::backend::submit_solution( - username_val.clone(), - puzzle_num, - solution_num, - pwd, - ) - .await - { - Ok(msg) => { - message.set(msg); - puzzle_id.set(String::new()); - solution.set(String::new()); - password.set(String::new()); - } - Err(e) => { - message.set(format!("Error: {}", e)); - } - } - } else { - message.set("Invalid solution number".to_string()); + match crate::backend::submit_solution( + username_current.clone(), + puzzle_val, + solution_val, + None, + pwd, + ) + .await + { + Ok(msg) => { + message.set(msg); + puzzle_id.set(String::new()); + solution.set(String::new()); + password.set(String::new()); + } + Err(e) => { + message.set(format!("Error: {}", e)); } - } else { - message.set("Invalid puzzle ID".to_string()); } } }); @@ -197,8 +191,8 @@ pub fn App() -> Element { thead { tr { th { class: "text-left pl-2", "." } - for puzzle in puzzles.read().iter() { - th { "Puzzle {puzzle}" } + for (id, value) in puzzles.read().iter() { + th { "Puzzle {id}" } } } } @@ -206,9 +200,9 @@ pub fn App() -> Element { for (team_name, solved) in teams_state.read().iter() { tr { td { class: "text-left pl-2 bg-(--dark2)", "{team_name}" } - for puzzle in puzzles.read().iter() { + for (puzzle_id, _puzzle) in puzzles.read().iter() { td { class: "bg-(--dark) text-center", - if solved.contains(puzzle) { + if solved.contains(puzzle_id) { "X" } else { "" From 2baf2dad65b78477cf4e360192c495a2bc54e064 Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 3 Dec 2025 12:10:31 +0100 Subject: [PATCH 07/70] chores(client): better variable naming scheme --- src/app.rs | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/app.rs b/src/app.rs index 69fc494..f9bfd3f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,7 +24,7 @@ pub fn App() -> Element { let mut username = use_signal(|| String::new()); let mut password = use_signal(|| String::new()); let mut puzzle_id = use_signal(|| String::new()); - let mut solution = use_signal(|| String::new()); + let mut puzzle_solution = use_signal(|| String::new()); let mut joined = use_signal(|| false); let mut is_admin = use_signal(|| false); let mut show_password_prompt = use_signal(|| false); @@ -48,20 +48,20 @@ pub fn App() -> Element { // Handle join/submit button click let handle_action = move |_| { spawn(async move { - let username_val = username.read().clone(); - let password_val = password.read().clone(); + let username_current = username.read().clone(); + let password_current = password.read().clone(); let is_joined = *joined.read(); let admin = *is_admin.read(); if !is_joined { // Check if username is admin before joining - if let Ok(is_admin_user) = check_admin_username(username_val.clone()).await { + if let Ok(is_admin_user) = check_admin_username(username_current.clone()).await { if is_admin_user { is_admin.set(true); show_password_prompt.set(true); // If password is empty, don't proceed yet - if password_val.is_empty() { + if password_current.is_empty() { message.set("Please enter admin password".to_string()); return; } @@ -72,12 +72,12 @@ pub fn App() -> Element { // Join team - call backend function directly let pwd = if admin || *show_password_prompt.read() { - Some(password_val.clone()) + Some(password_current.clone()) } else { None }; - match crate::backend::join(username_val.clone()).await { + match crate::backend::join(username_current.clone()).await { Ok(msg) => { message.set(msg); joined.set(true); @@ -90,19 +90,20 @@ pub fn App() -> Element { } } else { // Submit solution - call backend function directly - let puzzle_val = puzzle_id.read().clone(); - let solution_val = solution.read().clone(); + let puzzle_current = puzzle_id.read().clone(); + let solution_current = puzzle_solution.read().clone(); // let value_current = puzzle_value_FROMUI.read().clone(); + let pwd = if admin { - Some(password_val.clone()) + Some(password_current.clone()) } else { None }; match crate::backend::submit_solution( username_current.clone(), - puzzle_val, - solution_val, + puzzle_current, + solution_current, None, pwd, ) @@ -111,7 +112,7 @@ pub fn App() -> Element { Ok(msg) => { message.set(msg); puzzle_id.set(String::new()); - solution.set(String::new()); + puzzle_solution.set(String::new()); password.set(String::new()); } Err(e) => { @@ -163,8 +164,8 @@ pub fn App() -> Element { input { class: "ml-4 {INPUT}", r#type: "text", placeholder: "Solution", - value: "{solution}", - oninput: move |evt| solution.set(evt.value()) + value: "{puzzle_solution}", + oninput: move |evt| puzzle_solution.set(evt.value()) } if *is_admin.read() { From 927aaf6d53c245714b65fd3c4358372543c51b1e Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 3 Dec 2025 12:20:48 +0100 Subject: [PATCH 08/70] fix(client): support setting the puzzle value --- src/app.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index f9bfd3f..08c6be1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -25,6 +25,7 @@ pub fn App() -> Element { let mut password = use_signal(|| String::new()); let mut puzzle_id = use_signal(|| String::new()); let mut puzzle_solution = use_signal(|| String::new()); + let mut puzzle_value = use_signal(|| String::new()); let mut joined = use_signal(|| false); let mut is_admin = use_signal(|| false); let mut show_password_prompt = use_signal(|| false); @@ -92,7 +93,8 @@ pub fn App() -> Element { // Submit solution - call backend function directly let puzzle_current = puzzle_id.read().clone(); let solution_current = puzzle_solution.read().clone(); - // let value_current = puzzle_value_FROMUI.read().clone(); + let value_current = puzzle_value.read().clone(); + let value_current_num = value_current.parse::().unwrap(); let pwd = if admin { Some(password_current.clone()) @@ -104,7 +106,7 @@ pub fn App() -> Element { username_current.clone(), puzzle_current, solution_current, - None, + Some(value_current_num), pwd, ) .await @@ -168,6 +170,15 @@ pub fn App() -> Element { oninput: move |evt| puzzle_solution.set(evt.value()) } + if *is_admin.read() { + input { class: "ml-4 {INPUT}", + r#type: "text", + placeholder: "Puzlle Value", + value: "{puzzle_value}", + oninput: move |evt| puzzle_value.set(evt.value()) + } + } + if *is_admin.read() { input { class: "ml-4 {INPUT}", r#type: "password", From ee97344583726de3bdaf852d8055789c64247a76 Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 3 Dec 2025 12:23:36 +0100 Subject: [PATCH 09/70] feat(client): see puzzle value by table header hover --- Cargo.lock | 50 ++++++- Cargo.toml | 1 + assets/dx-components-theme.css | 83 ++++++++++++ assets/tailwind.css | 197 ++++++++++++++++++++++++++++ src/app.rs | 17 ++- src/components/mod.rs | 2 + src/components/tooltip/component.rs | 44 +++++++ src/components/tooltip/mod.rs | 2 + src/components/tooltip/style.css | 150 +++++++++++++++++++++ 9 files changed, 539 insertions(+), 7 deletions(-) create mode 100644 assets/dx-components-theme.css create mode 100644 src/components/mod.rs create mode 100644 src/components/tooltip/component.rs create mode 100644 src/components/tooltip/mod.rs create mode 100644 src/components/tooltip/style.css diff --git a/Cargo.lock b/Cargo.lock index 4a70e88..920b7a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,7 @@ name = "apollo" version = "0.1.0" dependencies = [ "dioxus", + "dioxus-primitives", "tokio", ] @@ -997,7 +998,7 @@ dependencies = [ "global-hotkey", "infer", "jni", - "lazy-js-bundle", + "lazy-js-bundle 0.7.1", "libc", "muda", "ndk", @@ -1069,7 +1070,7 @@ dependencies = [ "futures-channel", "futures-util", "generational-box", - "lazy-js-bundle", + "lazy-js-bundle 0.7.1", "serde", "serde_json", "tracing", @@ -1228,7 +1229,7 @@ dependencies = [ "futures-util", "generational-box", "keyboard-types", - "lazy-js-bundle", + "lazy-js-bundle 0.7.1", "rustversion", "serde", "serde_json", @@ -1258,7 +1259,7 @@ dependencies = [ "dioxus-core-types", "dioxus-html", "js-sys", - "lazy-js-bundle", + "lazy-js-bundle 0.7.1", "rustc-hash 2.1.1", "serde", "sledgehammer_bindgen", @@ -1308,6 +1309,19 @@ dependencies = [ "tracing-wasm", ] +[[package]] +name = "dioxus-primitives" +version = "0.0.1" +source = "git+https://github.com/DioxusLabs/components#30fd00d19802498cb5988e41d426d7c84e0df536" +dependencies = [ + "dioxus", + "dioxus-time", + "lazy-js-bundle 0.6.2", + "num-integer", + "time", + "tracing", +] + [[package]] name = "dioxus-router" version = "0.7.1" @@ -1465,6 +1479,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "dioxus-time" +version = "0.7.0" +source = "git+https://github.com/ealmloff/dioxus-std?branch=0.7#8c868ac1d60e3232e3f16f6195d6deb3c016de17" +dependencies = [ + "dioxus", + "futures", + "gloo-timers", + "tokio", +] + [[package]] name = "dioxus-web" version = "0.7.1" @@ -1486,7 +1511,7 @@ dependencies = [ "generational-box", "gloo-timers", "js-sys", - "lazy-js-bundle", + "lazy-js-bundle 0.7.1", "rustc-hash 2.1.1", "send_wrapper", "serde", @@ -2788,6 +2813,12 @@ dependencies = [ "selectors", ] +[[package]] +name = "lazy-js-bundle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49596223b9d9d4947a14a25c142a6e7d8ab3f27eb3ade269d238bb8b5c267e2" + [[package]] name = "lazy-js-bundle" version = "0.7.1" @@ -3228,6 +3259,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" diff --git a/Cargo.toml b/Cargo.toml index 83e29a1..63c527a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ edition = "2024" [dependencies] dioxus = { version = "0.7.1", features = ["fullstack"] } +dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } tokio = { version = "1.48.0", optional = true } [features] diff --git a/assets/dx-components-theme.css b/assets/dx-components-theme.css new file mode 100644 index 0000000..6d51a26 --- /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: 0; + background-color: var(--primary-color); + color: var(--secondary-color-4); + 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/tailwind.css b/assets/tailwind.css index bd7cbaa..cb4c49e 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -7,10 +7,25 @@ 'Noto Color Emoji'; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-gray-900: oklch(21% 0.034 264.665); + --color-white: #fff; + --spacing: 0.25rem; + --text-lg: 3rem; + --text-lg--line-height: calc(1.75 / 1.125); + --font-weight-bold: 700; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); + --dark: #13293d; + --dark2: #006494; + --middle: #247ba0; + --light: #e8f1f2; } } @layer base { @@ -186,9 +201,24 @@ max-width: 96rem; } } + .mt-5 { + margin-top: calc(var(--spacing) * 5); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .ml-4 { + margin-left: calc(var(--spacing) * 4); + } .table { display: table; } + .w-30 { + width: calc(var(--spacing) * 30); + } + .w-50 { + width: calc(var(--spacing) * 50); + } .border-collapse { border-collapse: collapse; } @@ -198,13 +228,74 @@ .resize { resize: both; } + .rounded-lg { + border-radius: var(--radius-lg); + } + .rounded-md { + border-radius: var(--radius-md); + } .border { border-style: var(--tw-border-style); border-width: 1px; } + .border-\(--dark2\) { + border-color: var(--dark2); + } + .border-gray-300 { + border-color: var(--color-gray-300); + } + .border-white { + border-color: var(--color-white); + } + .bg-\(--dark\) { + background-color: var(--dark); + } + .bg-\(--dark2\) { + background-color: var(--dark2); + } + .bg-\(--middle\) { + background-color: var(--middle); + } + .bg-white { + background-color: var(--color-white); + } + .p-2 { + padding: calc(var(--spacing) * 2); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .pl-2 { + padding-left: calc(var(--spacing) * 2); + } + .text-center { + text-align: center; + } + .text-left { + text-align: left; + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .text-gray-900 { + color: var(--color-gray-900); + } .underline { text-decoration-line: underline; } + .placeholder-gray-400 { + &::placeholder { + color: var(--color-gray-400); + } + } .outline { outline-style: var(--tw-outline-style); outline-width: 1px; @@ -214,6 +305,28 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .focus\:border-blue-500 { + &:focus { + border-color: var(--color-blue-500); + } + } + .focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-blue-500 { + &:focus { + --tw-ring-color: var(--color-blue-500); + } + } + .focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } + } } @property --tw-rotate-x { syntax: "*"; @@ -240,11 +353,80 @@ inherits: false; initial-value: solid; } +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} @property --tw-outline-style { syntax: "*"; inherits: false; initial-value: solid; } +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { @@ -254,7 +436,22 @@ --tw-skew-x: initial; --tw-skew-y: initial; --tw-border-style: solid; + --tw-font-weight: initial; --tw-outline-style: solid; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; } } } diff --git a/src/app.rs b/src/app.rs index 08c6be1..425636d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,9 @@ -use dioxus::prelude::*; +use dioxus::{html::th, prelude::*}; +use dioxus_primitives::{ContentAlign, ContentSide}; use crate::{ backend::{PuzzlesExisting, TeamsState}, + components::tooltip::*, }; const FAVICON: Asset = asset!("/assets/favicon.ico"); @@ -204,7 +206,18 @@ pub fn App() -> Element { tr { th { class: "text-left pl-2", "." } for (id, value) in puzzles.read().iter() { - th { "Puzzle {id}" } + th { + Tooltip { + TooltipTrigger { "Puzzle {id}" } + TooltipContent { + side: ContentSide::Top, + align: ContentAlign::Center, + div { class: "p-2 border border-(--dark2) rounded-md bg-(--dark)", + "value: {value}" + } + } + } + } } } } diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..dd4d440 --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,2 @@ +// AUTOGENERTED Components module +pub mod tooltip; diff --git a/src/components/tooltip/component.rs b/src/components/tooltip/component.rs new file mode 100644 index 0000000..a6486d2 --- /dev/null +++ b/src/components/tooltip/component.rs @@ -0,0 +1,44 @@ +use dioxus::prelude::*; +use dioxus_primitives::tooltip::{self, TooltipContentProps, TooltipProps, TooltipTriggerProps}; + +#[component] +pub fn Tooltip(props: TooltipProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + tooltip::Tooltip { + class: "tooltip", + disabled: props.disabled, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn TooltipTrigger(props: TooltipTriggerProps) -> Element { + rsx! { + tooltip::TooltipTrigger { + class: "tooltip-trigger", + id: props.id, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn TooltipContent(props: TooltipContentProps) -> Element { + rsx! { + tooltip::TooltipContent { + class: "tooltip-content", + id: props.id, + side: props.side, + align: props.align, + attributes: props.attributes, + {props.children} + } + } +} diff --git a/src/components/tooltip/mod.rs b/src/components/tooltip/mod.rs new file mode 100644 index 0000000..9a8ae55 --- /dev/null +++ b/src/components/tooltip/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/src/components/tooltip/style.css b/src/components/tooltip/style.css new file mode 100644 index 0000000..0388ab8 --- /dev/null +++ b/src/components/tooltip/style.css @@ -0,0 +1,150 @@ +/* Tooltip Styles */ +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip-trigger { + display: inline-block; +} + +.tooltip-content { + position: absolute; + z-index: 1000; + max-width: 250px; + padding: 8px 12px; + border-radius: 0.5rem; + animation: tooltip-fade-in 0.2s ease-in-out; + background-color: var(--secondary-color-4); + color: var(--primary-color); + font-size: 14px; + line-height: 1.4; +} + +.tooltip-content::after { + position: absolute; + border-width: 0.25rem; + border-style: solid; + margin-left: -0.25rem; + content: " "; + rotate: 45deg; +} + +/* Positioning based on side */ +.tooltip-content[data-side="top"] { + position: absolute; + bottom: 100%; + left: 50%; + margin-bottom: 8px; + transform: translateX(-50%); +} + +.tooltip-content[data-side="top"]::after { + top: calc(100% - 0.25rem); + left: 50%; + border-color: var(--secondary-color-4); + border-radius: 0 0 0.1rem; +} + +.tooltip-content[data-side="right"] { + position: absolute; + top: 50%; + left: 100%; + margin-left: 8px; + transform: translateY(-50%); +} + +.tooltip-content[data-side="right"]::after { + top: calc(50% - 0.25rem); + left: 0; + border-color: var(--secondary-color-4); + border-radius: 0 0 0 0.1rem; +} + +.tooltip-content[data-side="bottom"] { + position: absolute; + top: 100%; + left: 50%; + margin-top: 8px; + transform: translateX(-50%); +} + +.tooltip-content[data-side="bottom"]::after { + bottom: calc(100% - 0.25rem); + left: 50%; + border-color: var(--secondary-color-4); + border-radius: 0.1rem 0 0; +} + +.tooltip-content[data-side="left"] { + position: absolute; + top: 50%; + right: 100%; + margin-right: 8px; + transform: translateY(-50%); +} + +.tooltip-content[data-side="left"]::after { + top: calc(50% - 0.25rem); + right: -0.25rem; + border-color: var(--secondary-color-4); + border-radius: 0 0.1rem 0 0; +} + +/* Alignment styles for top and bottom */ +.tooltip-content[data-side="top"][data-align="start"], +.tooltip-content[data-side="bottom"][data-align="start"] { + left: 0; + transform: none; +} + +.tooltip-content[data-side="top"][data-align="end"], +.tooltip-content[data-side="bottom"][data-align="end"] { + right: 0; + left: auto; + transform: none; +} + +/* Alignment styles for left and right */ +.tooltip-content[data-side="left"][data-align="start"], +.tooltip-content[data-side="right"][data-align="start"] { + top: 0; + transform: none; +} + +.tooltip-content[data-side="left"][data-align="center"], +.tooltip-content[data-side="right"][data-align="center"] { + top: 50%; + transform: translateY(-50%); +} + +.tooltip-content[data-side="left"][data-align="end"], +.tooltip-content[data-side="right"][data-align="end"] { + top: auto; + bottom: 0; + transform: none; +} + +/* Animation */ +@keyframes tooltip-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +/* State styles */ +.tooltip[data-disabled="true"] .tooltip-trigger { + cursor: default; +} + +.tooltip-content[data-state="closed"] { + display: none; +} + +.tooltip-content[data-state="open"] { + display: block; +} From 1b6e47e67fef597675bb97d9b4b17439eb0f93b9 Mon Sep 17 00:00:00 2001 From: csboo Date: Fri, 5 Dec 2025 10:45:07 +0100 Subject: [PATCH 10/70] misc(client): mock data for backend --- backend_mock_data.patch | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 backend_mock_data.patch diff --git a/backend_mock_data.patch b/backend_mock_data.patch new file mode 100644 index 0000000..84b1702 --- /dev/null +++ b/backend_mock_data.patch @@ -0,0 +1,34 @@ +src/backend.rs --- Rust + 6 pub use models::*; 6 pub use models::*; + 7 mod models; 7 mod models; + 8 8 + 9 static PUZZLES: LazyLock> = 9 static PUZZLES: LazyLock> = LazyLock::new(|| + . . { +10 LazyLock::new(|| RwLock::new(PuzzleSolutions::new())); 10 RwLock::new(PuzzleSolutions::from([ +.. 11 ("1".to_string(), Puzzle::new("10".to_string(), 100)), +.. 12 ("2".to_string(), Puzzle::new("20".to_string(), 200)), +.. 13 ("3".to_string(), Puzzle::new("30".to_string(), 300)), +.. 14 ("4".to_string(), Puzzle::new("40".to_string(), 400)), +.. 15 ])) +.. 16 }); +11 17 +12 static TEAMS: LazyLock> = LazyLock::new(|| RwLock: 18 static TEAMS: LazyLock> = LazyLock::new(|| { +.. :new(TeamsState::new())); .. +.. 19 RwLock::new(TeamsState::from([ +.. 20 ("feco".to_string(), SolvedPuzzles::from(["1".to_string()])), +.. 21 ( +.. 22 "jero".to_string(), +.. 23 SolvedPuzzles::from(["1".to_string(), "2".to_string(), "3 +.. .. ".to_string()]), +.. 24 ), +.. 25 ( +.. 26 "karo".to_string(), +.. 27 SolvedPuzzles::from(["1".to_string(), "3".to_string()]), +.. 28 ), +.. 29 ("genyo".to_string(), SolvedPuzzles::from(["".to_string()])), +.. 30 ])) +.. 31 }); +13 32 +14 /// without `name`, the app won't run 33 /// without `name`, the app won't run +15 fn ensure_env_var(key: &str) -> String { 34 fn ensure_env_var(key: &str) -> String { + From 596d7ab7100d3d3655c97646b37a5025041aeafd Mon Sep 17 00:00:00 2001 From: csboo Date: Fri, 5 Dec 2025 10:45:58 +0100 Subject: [PATCH 11/70] chores(client): code cleanup + add missing import in main --- src/app.rs | 144 +++++++++++++++++++++++++--------------------------- src/main.rs | 1 + 2 files changed, 69 insertions(+), 76 deletions(-) diff --git a/src/app.rs b/src/app.rs index 425636d..2cd2c9d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use dioxus::{html::th, prelude::*}; +use dioxus::prelude::*; use dioxus_primitives::{ContentAlign, ContentSide}; use crate::{ @@ -8,12 +8,11 @@ use crate::{ const FAVICON: Asset = asset!("/assets/favicon.ico"); const MAIN_CSS: Asset = asset!("/assets/main.css"); -const HEADER_SVG: Asset = asset!("/assets/header.svg"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); const BUTTON: &str = "ml-4 w-30 px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; const INPUT: &str = "w-50 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"; -// Server function to check if username is admin (only one we need to add) +// TODO could be handeled in much better ways async fn check_admin_username(username: String) -> Result { // use std::env; let admin_username = "jani"; @@ -22,7 +21,7 @@ async fn check_admin_username(username: String) -> Result { #[component] pub fn App() -> Element { - // State management + // State management variables let mut username = use_signal(|| String::new()); let mut password = use_signal(|| String::new()); let mut puzzle_id = use_signal(|| String::new()); @@ -36,10 +35,10 @@ pub fn App() -> Element { let mut message = use_signal(|| String::new()); use_future(move || async move { - // Call the SSE endpoint to get a stream of events + // Call the stream endpoint to get a stream of events let mut stream = crate::backend::state_stream().await?; - // And then poll it for new events, adding them to our signal + // Then poll it for new events while let Some(Ok(data)) = stream.next().await { teams_state.set(data.0); puzzles.set(data.1); @@ -49,82 +48,74 @@ pub fn App() -> Element { }); // Handle join/submit button click - let handle_action = move |_| { - spawn(async move { - let username_current = username.read().clone(); - let password_current = password.read().clone(); - let is_joined = *joined.read(); - let admin = *is_admin.read(); - - if !is_joined { - // Check if username is admin before joining - if let Ok(is_admin_user) = check_admin_username(username_current.clone()).await { - if is_admin_user { - is_admin.set(true); - show_password_prompt.set(true); - - // If password is empty, don't proceed yet - if password_current.is_empty() { - message.set("Please enter admin password".to_string()); - return; - } - joined.set(true); + let handle_action = move |_| async move { + let username_current = username.read().clone(); + let password_current = password.read().clone(); + let is_joined = *joined.read(); + let admin = *is_admin.read(); + + if !is_joined { + // Check if username is admin before joining + if let Ok(is_admin_user) = check_admin_username(username_current.clone()).await { + if is_admin_user { + is_admin.set(true); + show_password_prompt.set(true); + + // If password is empty, don't proceed yet + if password_current.is_empty() { + message.set("Please enter admin password".to_string()); return; } + joined.set(true); + return; } - - // Join team - call backend function directly - let pwd = if admin || *show_password_prompt.read() { - Some(password_current.clone()) - } else { - None - }; - - match crate::backend::join(username_current.clone()).await { - Ok(msg) => { - message.set(msg); - joined.set(true); - password.set(String::new()); - show_password_prompt.set(false); - } - Err(e) => { - message.set(format!("Error: {}", e)); - } + }; + + match crate::backend::join(username_current.clone()).await { + Ok(msg) => { + message.set(msg.clone()); + joined.set(true); + password.set(String::new()); + show_password_prompt.set(false); + } + Err(e) => { + message.set(format!("Error: {}", e)); } + } + } else { + // Submit solution - call backend function directly + let puzzle_current = puzzle_id.read().clone(); + let solution_current = puzzle_solution.read().clone(); + let value_current = puzzle_value.read().clone(); + let value_current_num = value_current.parse::().unwrap(); + + let pwd = if admin { + Some(password_current.clone()) } else { - // Submit solution - call backend function directly - let puzzle_current = puzzle_id.read().clone(); - let solution_current = puzzle_solution.read().clone(); - let value_current = puzzle_value.read().clone(); - let value_current_num = value_current.parse::().unwrap(); - - let pwd = if admin { - Some(password_current.clone()) - } else { - None - }; - - match crate::backend::submit_solution( - username_current.clone(), - puzzle_current, - solution_current, - Some(value_current_num), - pwd, - ) - .await - { - Ok(msg) => { - message.set(msg); - puzzle_id.set(String::new()); - puzzle_solution.set(String::new()); - password.set(String::new()); - } - Err(e) => { - message.set(format!("Error: {}", e)); - } + None + }; + + match crate::backend::submit_solution( + username_current.clone(), + puzzle_current, + solution_current, + Some(value_current_num), + pwd, + ) + .await + { + Ok(msg) => { + message.set(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) => { + message.set(format!("Error: {}", e)); } } - }); + } }; rsx! { @@ -133,7 +124,8 @@ pub fn App() -> Element { document::Link { rel: "stylesheet", href: TAILWIND_CSS } div { class: "container", - h1 { "Apollo Hackathon Tracker" } + // TODO get from envpoint + h1 { class: "mb-4 font-bold text-lg", "EVENT TITLE PLACEHOLDER" } // Input section div { class: "input-section", diff --git a/src/main.rs b/src/main.rs index dcb1287..6be7a88 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(); From 978c25ddac53addd84a4ff2e5c61af32bf5d0276 Mon Sep 17 00:00:00 2001 From: csboo Date: Fri, 5 Dec 2025 11:29:40 +0100 Subject: [PATCH 12/70] misc(client): some logs --- src/app.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app.rs b/src/app.rs index 2cd2c9d..90efbb3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -21,7 +21,9 @@ async fn check_admin_username(username: String) -> Result { #[component] pub fn App() -> Element { + trace!("kicking off app"); // State management variables + trace!("initing variables"); let mut username = use_signal(|| String::new()); let mut password = use_signal(|| String::new()); let mut puzzle_id = use_signal(|| String::new()); @@ -33,15 +35,20 @@ pub fn App() -> Element { let mut teams_state = use_signal(|| TeamsState::new()); let mut puzzles = use_signal(|| PuzzlesExisting::new()); let mut message = use_signal(|| String::new()); + trace!("variables inited"); use_future(move || async move { // Call the stream endpoint to get a stream of events + trace!("calling state_stream"); let mut stream = crate::backend::state_stream().await?; + trace!("got stream"); // Then poll it for new events while let Some(Ok(data)) = stream.next().await { + trace!("got new data"); teams_state.set(data.0); puzzles.set(data.1); + trace!("set new data"); } dioxus::Ok(()) @@ -49,6 +56,7 @@ pub fn App() -> Element { // Handle join/submit button click let handle_action = move |_| async move { + trace!("action handler called"); let username_current = username.read().clone(); let password_current = password.read().clone(); let is_joined = *joined.read(); @@ -87,6 +95,11 @@ pub fn App() -> Element { let puzzle_current = puzzle_id.read().clone(); let solution_current = puzzle_solution.read().clone(); let value_current = puzzle_value.read().clone(); + // trace!( + // "value is '{}' is_empty: '{}'", + // &value_current, + // &value_current.is_empty() + // ); let value_current_num = value_current.parse::().unwrap(); let pwd = if admin { From a50612d3ea16984a84860ce9b9039ec193ff874a Mon Sep 17 00:00:00 2001 From: csboo Date: Fri, 5 Dec 2025 11:52:04 +0100 Subject: [PATCH 13/70] feat(client): server message as popup --- Cargo.lock | 1 + Cargo.toml | 1 + assets/main.css | 32 ++++++++++++++++++++++++++++++++ src/app.rs | 33 +++++++++++++++++++++++++-------- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 920b7a4..8a85879 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,7 @@ version = "0.1.0" dependencies = [ "dioxus", "dioxus-primitives", + "gloo-timers", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 63c527a..f5c59e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ edition = "2024" [dependencies] dioxus = { version = "0.7.1", features = ["fullstack"] } dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } +gloo-timers = { version = "0.3.0", features = ["futures"] } tokio = { version = "1.48.0", optional = true } [features] diff --git a/assets/main.css b/assets/main.css index 5f58108..d926cc9 100644 --- a/assets/main.css +++ b/assets/main.css @@ -1,6 +1,38 @@ /* colors */ +.popup { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: white; + padding: 12px 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 4.7s forwards; +} + +@keyframes fadein { + from { + opacity: 0; + transform: translateX(-50%) translateY(10px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes fadeout { + to { + opacity: 0; + transform: translateX(-50%) translateY(10px); + } +} body { background-color: #0f1116; diff --git a/src/app.rs b/src/app.rs index 90efbb3..819b974 100644 --- a/src/app.rs +++ b/src/app.rs @@ -34,9 +34,20 @@ pub fn App() -> Element { let mut show_password_prompt = use_signal(|| false); let mut teams_state = use_signal(|| TeamsState::new()); let mut puzzles = use_signal(|| PuzzlesExisting::new()); - let mut message = use_signal(|| String::new()); + let mut message = use_signal(|| None::); trace!("variables inited"); + use_effect(move || { + if message.read().is_some() { + // hide after 5 seconds + // let message = message.clone(); + spawn(async move { + gloo_timers::future::sleep(std::time::Duration::from_secs(5)).await; + message.set(None); + }); + } + }); + use_future(move || async move { // Call the stream endpoint to get a stream of events trace!("calling state_stream"); @@ -71,7 +82,7 @@ pub fn App() -> Element { // If password is empty, don't proceed yet if password_current.is_empty() { - message.set("Please enter admin password".to_string()); + message.set(Some("Please enter admin password".to_string())); return; } joined.set(true); @@ -81,13 +92,13 @@ pub fn App() -> Element { match crate::backend::join(username_current.clone()).await { Ok(msg) => { - message.set(msg.clone()); + message.set(Some(msg.clone())); joined.set(true); password.set(String::new()); show_password_prompt.set(false); } Err(e) => { - message.set(format!("Error: {}", e)); + message.set(Some(format!("Error: {}", e))); } } } else { @@ -118,14 +129,14 @@ pub fn App() -> Element { .await { Ok(msg) => { - message.set(msg); + message.set(Some(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) => { - message.set(format!("Error: {}", e)); + message.set(Some(format!("Error: {}", e))); } } } @@ -200,8 +211,14 @@ pub fn App() -> Element { } // Message display - if !message.read().is_empty() { - div { class: "message", "{message}" } + // if !message.read().is_empty() { + // div { class: "message", "{message}" } + // } + if let Some(msg) = &*message.read() { + div { + class: "popup", + "{msg}" + } } // Teams and puzzles table From a4d4799bfe8c6e27234f9529c4e0dcccbbe98b31 Mon Sep 17 00:00:00 2001 From: csboo Date: Fri, 5 Dec 2025 12:05:59 +0100 Subject: [PATCH 14/70] fix(client): fixed wrong patch file --- backend_mock_data.patch | 71 +++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 34 deletions(-) diff --git a/backend_mock_data.patch b/backend_mock_data.patch index 84b1702..101092e 100644 --- a/backend_mock_data.patch +++ b/backend_mock_data.patch @@ -1,34 +1,37 @@ -src/backend.rs --- Rust - 6 pub use models::*; 6 pub use models::*; - 7 mod models; 7 mod models; - 8 8 - 9 static PUZZLES: LazyLock> = 9 static PUZZLES: LazyLock> = LazyLock::new(|| - . . { -10 LazyLock::new(|| RwLock::new(PuzzleSolutions::new())); 10 RwLock::new(PuzzleSolutions::from([ -.. 11 ("1".to_string(), Puzzle::new("10".to_string(), 100)), -.. 12 ("2".to_string(), Puzzle::new("20".to_string(), 200)), -.. 13 ("3".to_string(), Puzzle::new("30".to_string(), 300)), -.. 14 ("4".to_string(), Puzzle::new("40".to_string(), 400)), -.. 15 ])) -.. 16 }); -11 17 -12 static TEAMS: LazyLock> = LazyLock::new(|| RwLock: 18 static TEAMS: LazyLock> = LazyLock::new(|| { -.. :new(TeamsState::new())); .. -.. 19 RwLock::new(TeamsState::from([ -.. 20 ("feco".to_string(), SolvedPuzzles::from(["1".to_string()])), -.. 21 ( -.. 22 "jero".to_string(), -.. 23 SolvedPuzzles::from(["1".to_string(), "2".to_string(), "3 -.. .. ".to_string()]), -.. 24 ), -.. 25 ( -.. 26 "karo".to_string(), -.. 27 SolvedPuzzles::from(["1".to_string(), "3".to_string()]), -.. 28 ), -.. 29 ("genyo".to_string(), SolvedPuzzles::from(["".to_string()])), -.. 30 ])) -.. 31 }); -13 32 -14 /// without `name`, the app won't run 33 /// without `name`, the app won't run -15 fn ensure_env_var(key: &str) -> String { 34 fn ensure_env_var(key: &str) -> String { - +diff --git a/src/backend.rs b/src/backend.rs +index 663f9c1..072ed95 100644 +--- a/src/backend.rs ++++ b/src/backend.rs +@@ -6,10 +6,29 @@ use std::{env, process}; + pub use models::*; + mod models; + +-static PUZZLES: LazyLock> = +- LazyLock::new(|| RwLock::new(PuzzleSolutions::new())); ++static PUZZLES: LazyLock> = LazyLock::new(|| { ++ RwLock::new(PuzzleSolutions::from([ ++ ("1".to_string(), Puzzle::new("10".to_string(), 100)), ++ ("2".to_string(), Puzzle::new("20".to_string(), 200)), ++ ("3".to_string(), Puzzle::new("30".to_string(), 300)), ++ ("4".to_string(), Puzzle::new("40".to_string(), 400)), ++ ])) ++}); + +-static TEAMS: LazyLock> = LazyLock::new(|| RwLock::new(TeamsState::new())); ++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 `name`, the app won't run + fn ensure_env_var(key: &str) -> String { From a5f0f442f624fd92cd1650304c437e9ccaaad1a4 Mon Sep 17 00:00:00 2001 From: csboo Date: Fri, 5 Dec 2025 22:39:55 +0100 Subject: [PATCH 15/70] fix(client): support new api endpoints --- .gitignore | 5 + Cargo.lock | 1 + Cargo.toml | 1 + assets/tailwind.css | 457 ---------------------------------------- backend_mock_data.patch | 36 +++- src/app.rs | 58 ++++- src/backend.rs | 75 ++++--- src/backend/models.rs | 7 +- 8 files changed, 135 insertions(+), 505 deletions(-) delete mode 100644 assets/tailwind.css diff --git a/.gitignore b/.gitignore index 80aab8e..6e2fa0d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ # These are backup files generated by rustfmt **/*.rs.bk + +# Server state backups +apollo-state.cbor + +assets/tailwind.css diff --git a/Cargo.lock b/Cargo.lock index 8a85879..5ed095c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" name = "apollo" version = "0.1.0" dependencies = [ + "ciborium", "dioxus", "dioxus-primitives", "gloo-timers", diff --git a/Cargo.toml b/Cargo.toml index f5c59e6..b0c335e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +ciborium = "0.2.2" dioxus = { version = "0.7.1", features = ["fullstack"] } dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } gloo-timers = { version = "0.3.0", features = ["futures"] } diff --git a/assets/tailwind.css b/assets/tailwind.css deleted file mode 100644 index cb4c49e..0000000 --- a/assets/tailwind.css +++ /dev/null @@ -1,457 +0,0 @@ -/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */ -@layer properties; -@layer theme, base, components, utilities; -@layer theme { - :root, :host { - --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', - monospace; - --color-blue-500: oklch(62.3% 0.214 259.815); - --color-gray-300: oklch(87.2% 0.01 258.338); - --color-gray-400: oklch(70.7% 0.022 261.325); - --color-gray-900: oklch(21% 0.034 264.665); - --color-white: #fff; - --spacing: 0.25rem; - --text-lg: 3rem; - --text-lg--line-height: calc(1.75 / 1.125); - --font-weight-bold: 700; - --radius-md: 0.375rem; - --radius-lg: 0.5rem; - --default-transition-duration: 150ms; - --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - --default-font-family: var(--font-sans); - --default-mono-font-family: var(--font-mono); - --dark: #13293d; - --dark2: #006494; - --middle: #247ba0; - --light: #e8f1f2; - } -} -@layer base { - *, ::after, ::before, ::backdrop, ::file-selector-button { - box-sizing: border-box; - margin: 0; - padding: 0; - border: 0 solid; - } - html, :host { - line-height: 1.5; - -webkit-text-size-adjust: 100%; - tab-size: 4; - font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); - font-feature-settings: var(--default-font-feature-settings, normal); - font-variation-settings: var(--default-font-variation-settings, normal); - -webkit-tap-highlight-color: transparent; - } - hr { - height: 0; - color: inherit; - border-top-width: 1px; - } - abbr:where([title]) { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; - } - h1, h2, h3, h4, h5, h6 { - font-size: inherit; - font-weight: inherit; - } - a { - color: inherit; - -webkit-text-decoration: inherit; - text-decoration: inherit; - } - b, strong { - font-weight: bolder; - } - code, kbd, samp, pre { - font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace); - font-feature-settings: var(--default-mono-font-feature-settings, normal); - font-variation-settings: var(--default-mono-font-variation-settings, normal); - font-size: 1em; - } - small { - font-size: 80%; - } - sub, sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; - } - sub { - bottom: -0.25em; - } - sup { - top: -0.5em; - } - table { - text-indent: 0; - border-color: inherit; - border-collapse: collapse; - } - :-moz-focusring { - outline: auto; - } - progress { - vertical-align: baseline; - } - summary { - display: list-item; - } - ol, ul, menu { - list-style: none; - } - img, svg, video, canvas, audio, iframe, embed, object { - display: block; - vertical-align: middle; - } - img, video { - max-width: 100%; - height: auto; - } - button, input, select, optgroup, textarea, ::file-selector-button { - font: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - letter-spacing: inherit; - color: inherit; - border-radius: 0; - background-color: transparent; - opacity: 1; - } - :where(select:is([multiple], [size])) optgroup { - font-weight: bolder; - } - :where(select:is([multiple], [size])) optgroup option { - padding-inline-start: 20px; - } - ::file-selector-button { - margin-inline-end: 4px; - } - ::placeholder { - opacity: 1; - } - @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { - ::placeholder { - color: currentcolor; - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, currentcolor 50%, transparent); - } - } - } - textarea { - resize: vertical; - } - ::-webkit-search-decoration { - -webkit-appearance: none; - } - ::-webkit-date-and-time-value { - min-height: 1lh; - text-align: inherit; - } - ::-webkit-datetime-edit { - display: inline-flex; - } - ::-webkit-datetime-edit-fields-wrapper { - padding: 0; - } - ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { - padding-block: 0; - } - :-moz-ui-invalid { - box-shadow: none; - } - button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { - appearance: button; - } - ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { - height: auto; - } - [hidden]:where(:not([hidden='until-found'])) { - display: none !important; - } -} -@layer utilities { - .visible { - visibility: visible; - } - .relative { - position: relative; - } - .static { - position: static; - } - .container { - width: 100%; - @media (width >= 40rem) { - max-width: 40rem; - } - @media (width >= 48rem) { - max-width: 48rem; - } - @media (width >= 64rem) { - max-width: 64rem; - } - @media (width >= 80rem) { - max-width: 80rem; - } - @media (width >= 96rem) { - max-width: 96rem; - } - } - .mt-5 { - margin-top: calc(var(--spacing) * 5); - } - .mb-4 { - margin-bottom: calc(var(--spacing) * 4); - } - .ml-4 { - margin-left: calc(var(--spacing) * 4); - } - .table { - display: table; - } - .w-30 { - width: calc(var(--spacing) * 30); - } - .w-50 { - width: calc(var(--spacing) * 50); - } - .border-collapse { - border-collapse: collapse; - } - .transform { - transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); - } - .resize { - resize: both; - } - .rounded-lg { - border-radius: var(--radius-lg); - } - .rounded-md { - border-radius: var(--radius-md); - } - .border { - border-style: var(--tw-border-style); - border-width: 1px; - } - .border-\(--dark2\) { - border-color: var(--dark2); - } - .border-gray-300 { - border-color: var(--color-gray-300); - } - .border-white { - border-color: var(--color-white); - } - .bg-\(--dark\) { - background-color: var(--dark); - } - .bg-\(--dark2\) { - background-color: var(--dark2); - } - .bg-\(--middle\) { - background-color: var(--middle); - } - .bg-white { - background-color: var(--color-white); - } - .p-2 { - padding: calc(var(--spacing) * 2); - } - .px-3 { - padding-inline: calc(var(--spacing) * 3); - } - .py-2 { - padding-block: calc(var(--spacing) * 2); - } - .pl-2 { - padding-left: calc(var(--spacing) * 2); - } - .text-center { - text-align: center; - } - .text-left { - text-align: left; - } - .text-lg { - font-size: var(--text-lg); - line-height: var(--tw-leading, var(--text-lg--line-height)); - } - .font-bold { - --tw-font-weight: var(--font-weight-bold); - font-weight: var(--font-weight-bold); - } - .text-gray-900 { - color: var(--color-gray-900); - } - .underline { - text-decoration-line: underline; - } - .placeholder-gray-400 { - &::placeholder { - color: var(--color-gray-400); - } - } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } - .transition { - transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - } - .focus\:border-blue-500 { - &:focus { - border-color: var(--color-blue-500); - } - } - .focus\:ring-2 { - &:focus { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } - .focus\:ring-blue-500 { - &:focus { - --tw-ring-color: var(--color-blue-500); - } - } - .focus\:outline-none { - &:focus { - --tw-outline-style: none; - outline-style: none; - } - } -} -@property --tw-rotate-x { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-y { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-z { - syntax: "*"; - inherits: false; -} -@property --tw-skew-x { - syntax: "*"; - inherits: false; -} -@property --tw-skew-y { - syntax: "*"; - inherits: false; -} -@property --tw-border-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} -@property --tw-font-weight { - syntax: "*"; - inherits: false; -} -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} -@property --tw-shadow { - syntax: "*"; - inherits: false; - initial-value: 0 0 #0000; -} -@property --tw-shadow-color { - syntax: "*"; - inherits: false; -} -@property --tw-shadow-alpha { - syntax: ""; - inherits: false; - initial-value: 100%; -} -@property --tw-inset-shadow { - syntax: "*"; - inherits: false; - initial-value: 0 0 #0000; -} -@property --tw-inset-shadow-color { - syntax: "*"; - inherits: false; -} -@property --tw-inset-shadow-alpha { - syntax: ""; - inherits: false; - initial-value: 100%; -} -@property --tw-ring-color { - syntax: "*"; - inherits: false; -} -@property --tw-ring-shadow { - syntax: "*"; - inherits: false; - initial-value: 0 0 #0000; -} -@property --tw-inset-ring-color { - syntax: "*"; - inherits: false; -} -@property --tw-inset-ring-shadow { - syntax: "*"; - inherits: false; - initial-value: 0 0 #0000; -} -@property --tw-ring-inset { - syntax: "*"; - inherits: false; -} -@property --tw-ring-offset-width { - syntax: ""; - inherits: false; - initial-value: 0px; -} -@property --tw-ring-offset-color { - syntax: "*"; - inherits: false; - initial-value: #fff; -} -@property --tw-ring-offset-shadow { - syntax: "*"; - inherits: false; - initial-value: 0 0 #0000; -} -@layer properties { - @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { - *, ::before, ::after, ::backdrop { - --tw-rotate-x: initial; - --tw-rotate-y: initial; - --tw-rotate-z: initial; - --tw-skew-x: initial; - --tw-skew-y: initial; - --tw-border-style: solid; - --tw-font-weight: initial; - --tw-outline-style: solid; - --tw-shadow: 0 0 #0000; - --tw-shadow-color: initial; - --tw-shadow-alpha: 100%; - --tw-inset-shadow: 0 0 #0000; - --tw-inset-shadow-color: initial; - --tw-inset-shadow-alpha: 100%; - --tw-ring-color: initial; - --tw-ring-shadow: 0 0 #0000; - --tw-inset-ring-color: initial; - --tw-inset-ring-shadow: 0 0 #0000; - --tw-ring-inset: initial; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-offset-shadow: 0 0 #0000; - } - } -} diff --git a/backend_mock_data.patch b/backend_mock_data.patch index 101092e..13e0b22 100644 --- a/backend_mock_data.patch +++ b/backend_mock_data.patch @@ -1,8 +1,8 @@ diff --git a/src/backend.rs b/src/backend.rs -index 663f9c1..072ed95 100644 +index 663f9c1..ff4c693 100644 --- a/src/backend.rs +++ b/src/backend.rs -@@ -6,10 +6,29 @@ use std::{env, process}; +@@ -6,10 +6,53 @@ use std::{env, process}; pub use models::*; mod models; @@ -10,10 +10,34 @@ index 663f9c1..072ed95 100644 - LazyLock::new(|| RwLock::new(PuzzleSolutions::new())); +static PUZZLES: LazyLock> = LazyLock::new(|| { + RwLock::new(PuzzleSolutions::from([ -+ ("1".to_string(), Puzzle::new("10".to_string(), 100)), -+ ("2".to_string(), Puzzle::new("20".to_string(), 200)), -+ ("3".to_string(), Puzzle::new("30".to_string(), 300)), -+ ("4".to_string(), Puzzle::new("40".to_string(), 400)), ++ ( ++ "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, ++ }, ++ ), + ])) +}); diff --git a/src/app.rs b/src/app.rs index 819b974..16bf6dc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ use dioxus::prelude::*; use dioxus_primitives::{ContentAlign, ContentSide}; use crate::{ - backend::{PuzzlesExisting, TeamsState}, + backend::{Puzzle, PuzzleSolutions, PuzzlesExisting, TeamsState}, components::tooltip::*, }; @@ -35,8 +35,18 @@ pub fn App() -> Element { let mut teams_state = use_signal(|| TeamsState::new()); let mut puzzles = use_signal(|| PuzzlesExisting::new()); let mut message = use_signal(|| None::); + let mut title = use_signal(|| None::); trace!("variables inited"); + use_future(move || async move { + title.set( + crate::backend::event_title() + .await + .inspect_err(|e| message.set(Some(format!("Error: {}", e)))) + .ok(), + ); + }); + use_effect(move || { if message.read().is_some() { // hide after 5 seconds @@ -66,6 +76,7 @@ pub fn App() -> Element { }); // Handle join/submit button click + // TODO this is very ugly function thing make it better let handle_action = move |_| async move { trace!("action handler called"); let username_current = username.read().clone(); @@ -111,20 +122,38 @@ pub fn App() -> Element { // &value_current, // &value_current.is_empty() // ); - let value_current_num = value_current.parse::().unwrap(); - - let pwd = if admin { - Some(password_current.clone()) - } else { - None - }; + if admin { + let value_current_num = value_current.parse::().unwrap(); + match crate::backend::set_solution( + PuzzleSolutions::from([( + puzzle_current, + Puzzle { + value: value_current_num, + solution: solution_current, + }, + )]), + password_current, + ) + .await + { + Ok(msg) => { + message.set(Some(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) => { + message.set(Some(format!("Error: {}", e))); + } + } + return; + } match crate::backend::submit_solution( username_current.clone(), puzzle_current, solution_current, - Some(value_current_num), - pwd, ) .await { @@ -149,7 +178,14 @@ pub fn App() -> Element { div { class: "container", // TODO get from envpoint - h1 { class: "mb-4 font-bold text-lg", "EVENT TITLE PLACEHOLDER" } + // h1 { class: "mb-4 font-bold text-lg", "EVENT TITLE PLACEHOLDER" } + h1 { class: "mb-4 font-bold text-lg", + if let Some(t) = &*title.read() { + "{t}", + } else { + "Betöltés..." + } + } // Input section div { class: "input-section", diff --git a/src/backend.rs b/src/backend.rs index 663f9c1..a621b5d 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -28,13 +28,18 @@ fn ensure_env_var(key: &str) -> String { /// if necessary admin env vars aren't set pub fn ensure_admin_env_vars() { _ = LazyLock::force(&ADMIN_PASSWORD); - _ = LazyLock::force(&ADMIN_USERNAME); } -pub static ADMIN_USERNAME: LazyLock = LazyLock::new(|| ensure_env_var("APOLLO_MESTER_NEV")); static ADMIN_PASSWORD: LazyLock = LazyLock::new(|| ensure_env_var("APOLLO_MESTER_JELSZO")); +static EVENT_TITLE: LazyLock> = + LazyLock::new(|| env::var("APOLLO_EVENT_TITLE")); -fn get_game_state() -> (TeamsState, PuzzlesExisting) { +#[get("/api/event_title")] +pub async fn event_title() -> Result { + Ok(EVENT_TITLE.clone()?) +} + +async fn get_game_state() -> (TeamsState, PuzzlesExisting) { let existing_puzzles = PUZZLES .read() .unwrap() @@ -45,11 +50,26 @@ fn get_game_state() -> (TeamsState, PuzzlesExisting) { (TEAMS.read().unwrap().clone(), existing_puzzles) } +/// just save a copy of the `PUZZLES` and `TEAMS` state to disk into a `cbor` file +/// TODO: add basic encryption using `ADMIN_PASSWORD` +#[cfg(feature = "server")] +async fn backup_state() -> Result<()> { + let teams_state = TEAMS.read().unwrap().clone(); + let puzzles_state = PUZZLES.read().unwrap().clone(); + let mut buf = vec![]; + ciborium::into_writer(&(teams_state, puzzles_state), &mut buf) + .inspect_err(|e| error!("couldn't serialize into cbor: {e}"))?; + tokio::fs::write("apollo-state.cbor", buf) + .await + .inspect_err(|e| error!("couldn't write state to file: {e}"))?; + Ok(()) +} + /// streams current progress of the teams and existing puzzles with their values -#[get("/api/state_json_stream")] +#[get("/api/state")] pub async fn state_stream() -> Result> { Ok(Streaming::spawn(|tx| async move { - while tx.unbounded_send(get_game_state()).is_ok() { + while tx.unbounded_send(get_game_state().await).is_ok() { tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; } })) @@ -58,37 +78,42 @@ pub async fn state_stream() -> Result Result { + _ = backup_state().await; let teams = &mut TEAMS.write().unwrap(); - (username != *ADMIN_USERNAME && !teams.contains_key(&username)) - .or_forbidden("taken username")?; + (!teams.contains_key(&username)).or_forbidden("taken username")?; _ = teams.insert(username, SolvedPuzzles::new()); Ok(String::from("helo, mehet!")) } -/// submit a solution either as a team, or as `ADMIN_USERNAME` with a `password` +/// set `puzzle_id`'s a `solution` and `value` with `ADMIN_PASSWORD` +#[post("/api/set_solution")] +pub async fn set_solution( + puzzle_solutions: PuzzleSolutions, + password: String, +) -> Result { + _ = backup_state().await; + // submitting as admin + (*ADMIN_PASSWORD == password).or_unauthorized("incorrect password for APOLLO_MESTER")?; + + let puzzles_state = &mut PUZZLES.write().unwrap(); + puzzle_solutions + .keys() + .any(|new_k| !puzzles_state.contains_key(new_k)) + .or_forbidden("one of the puzzles already set")?; + + puzzles_state.extend(puzzle_solutions); + + Ok(String::from("beallitottam a megoldast")) +} + +/// submit a solution as a team #[post("/api/submit")] pub async fn submit_solution( username: String, puzzle_id: PuzzleId, solution: PuzzleSolution, - // only needed if submitting (setting) as `ADMIN` - value: Option, - password: Option, ) -> Result { - // submitting as admin - if *ADMIN_USERNAME == username { - if *ADMIN_PASSWORD != password.or_bad_request("password is required for APOLLO_MESTER")? { - return HttpError::unauthorized("incorrect password for APOLLO_MESTER")?; - } - - let puzzles = &mut PUZZLES.write().unwrap(); - (!puzzles.contains_key(&puzzle_id)) - .or_forbidden("a solution for this puzzle is already set")?; - let set_puzzle = Puzzle::new(solution, value.or_bad_request("missing solution")?); - puzzles.insert(puzzle_id, set_puzzle); - return Ok("beallitottam a megoldast".to_string()); - } - + _ = backup_state().await; let teams = &mut TEAMS.write().unwrap(); let team_state = teams .get_mut(&username) diff --git a/src/backend/models.rs b/src/backend/models.rs index 8c0803c..9831fe7 100644 --- a/src/backend/models.rs +++ b/src/backend/models.rs @@ -8,17 +8,12 @@ pub struct Puzzle { /// how much it's worth pub value: PuzzleValue, } -impl Puzzle { - pub fn new(solution: PuzzleSolution, value: u32) -> Self { - Self { solution, value } - } -} pub type PuzzleId = String; pub type PuzzleValue = u32; pub type PuzzleSolution = String; pub type PuzzlesExisting = HashMap; -pub(super) type PuzzleSolutions = HashMap; +pub type PuzzleSolutions = HashMap; /// solved puzzles of a team, or existing puzzles in general pub type SolvedPuzzles = BTreeSet; pub type TeamsState = HashMap; From f13ad114ef3cd0110db243779955af1b8aeb1bdf Mon Sep 17 00:00:00 2001 From: csboo Date: Fri, 5 Dec 2025 23:01:55 +0100 Subject: [PATCH 16/70] fix(client): support latest api models --- backend_mock_data.patch | 23 +++---- src/app.rs | 12 ++-- src/backend.rs | 138 ++------------------------------------- src/backend/endpoints.rs | 80 +++++++++++++++++++++++ src/backend/logic.rs | 59 +++++++++++++++++ 5 files changed, 162 insertions(+), 150 deletions(-) create mode 100644 src/backend/endpoints.rs create mode 100644 src/backend/logic.rs diff --git a/backend_mock_data.patch b/backend_mock_data.patch index 13e0b22..391ef7f 100644 --- a/backend_mock_data.patch +++ b/backend_mock_data.patch @@ -1,14 +1,14 @@ -diff --git a/src/backend.rs b/src/backend.rs -index 663f9c1..ff4c693 100644 ---- a/src/backend.rs -+++ b/src/backend.rs -@@ -6,10 +6,53 @@ use std::{env, process}; - pub use models::*; - mod models; +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::sync::{LazyLock, RwLock}; + use std::{env, process}; --static PUZZLES: LazyLock> = +-pub(super) static PUZZLES: LazyLock> = - LazyLock::new(|| RwLock::new(PuzzleSolutions::new())); -+static PUZZLES: LazyLock> = LazyLock::new(|| { ++pub(super) static PUZZLES: LazyLock> = LazyLock::new(|| { + RwLock::new(PuzzleSolutions::from([ + ( + "1".to_string(), @@ -41,8 +41,9 @@ index 663f9c1..ff4c693 100644 + ])) +}); --static TEAMS: LazyLock> = LazyLock::new(|| RwLock::new(TeamsState::new())); -+static TEAMS: LazyLock> = LazyLock::new(|| { +-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()])), + ( diff --git a/src/app.rs b/src/app.rs index 16bf6dc..92dc442 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ use dioxus::prelude::*; use dioxus_primitives::{ContentAlign, ContentSide}; use crate::{ - backend::{Puzzle, PuzzleSolutions, PuzzlesExisting, TeamsState}, + backend::models::{Puzzle, PuzzleSolutions, PuzzlesExisting, TeamsState}, components::tooltip::*, }; @@ -40,7 +40,7 @@ pub fn App() -> Element { use_future(move || async move { title.set( - crate::backend::event_title() + crate::backend::endpoints::event_title() .await .inspect_err(|e| message.set(Some(format!("Error: {}", e)))) .ok(), @@ -61,7 +61,7 @@ pub fn App() -> Element { use_future(move || async move { // Call the stream endpoint to get a stream of events trace!("calling state_stream"); - let mut stream = crate::backend::state_stream().await?; + let mut stream = crate::backend::endpoints::state_stream().await?; trace!("got stream"); // Then poll it for new events @@ -101,7 +101,7 @@ pub fn App() -> Element { } }; - match crate::backend::join(username_current.clone()).await { + match crate::backend::endpoints::join(username_current.clone()).await { Ok(msg) => { message.set(Some(msg.clone())); joined.set(true); @@ -124,7 +124,7 @@ pub fn App() -> Element { // ); if admin { let value_current_num = value_current.parse::().unwrap(); - match crate::backend::set_solution( + match crate::backend::endpoints::set_solution( PuzzleSolutions::from([( puzzle_current, Puzzle { @@ -150,7 +150,7 @@ pub fn App() -> Element { return; } - match crate::backend::submit_solution( + match crate::backend::endpoints::submit_solution( username_current.clone(), puzzle_current, solution_current, diff --git a/src/backend.rs b/src/backend.rs index a621b5d..0fed89d 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -1,136 +1,8 @@ -use dioxus::fullstack::{CborEncoding, Streaming}; -use dioxus::prelude::*; -use std::sync::{LazyLock, RwLock}; -use std::{env, process}; +pub mod models; -pub use models::*; -mod models; - -static PUZZLES: LazyLock> = - LazyLock::new(|| RwLock::new(PuzzleSolutions::new())); - -static TEAMS: LazyLock> = LazyLock::new(|| RwLock::new(TeamsState::new())); - -/// without `name`, the app won't run -fn ensure_env_var(key: &str) -> String { - let Ok(value) = env::var(key) else { - error!("{key:?} env var not set, can't proceed"); - process::exit(1); - }; - if value.is_empty() { - error!("{key:?} env var empty, can't proceed"); - process::exit(1); - } - value -} - -/// # exits with 1 -/// if necessary admin env vars aren't set -pub fn ensure_admin_env_vars() { - _ = LazyLock::force(&ADMIN_PASSWORD); -} - -static ADMIN_PASSWORD: LazyLock = LazyLock::new(|| ensure_env_var("APOLLO_MESTER_JELSZO")); -static EVENT_TITLE: LazyLock> = - LazyLock::new(|| env::var("APOLLO_EVENT_TITLE")); - -#[get("/api/event_title")] -pub async fn event_title() -> Result { - Ok(EVENT_TITLE.clone()?) -} - -async fn get_game_state() -> (TeamsState, PuzzlesExisting) { - let existing_puzzles = PUZZLES - .read() - .unwrap() - .clone() - .into_iter() - .map(|(id, sol)| (id, sol.value)) - .collect(); - (TEAMS.read().unwrap().clone(), existing_puzzles) -} - -/// just save a copy of the `PUZZLES` and `TEAMS` state to disk into a `cbor` file -/// TODO: add basic encryption using `ADMIN_PASSWORD` #[cfg(feature = "server")] -async fn backup_state() -> Result<()> { - let teams_state = TEAMS.read().unwrap().clone(); - let puzzles_state = PUZZLES.read().unwrap().clone(); - let mut buf = vec![]; - ciborium::into_writer(&(teams_state, puzzles_state), &mut buf) - .inspect_err(|e| error!("couldn't serialize into cbor: {e}"))?; - tokio::fs::write("apollo-state.cbor", buf) - .await - .inspect_err(|e| error!("couldn't write state to file: {e}"))?; - Ok(()) -} - -/// streams current progress of the teams and existing puzzles with their values -#[get("/api/state")] -pub async fn state_stream() -> Result> { - Ok(Streaming::spawn(|tx| async move { - while tx.unbounded_send(get_game_state().await).is_ok() { - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - })) -} - -/// join the competition as a contestant team -#[post("/api/join")] -pub async fn join(username: String) -> Result { - _ = backup_state().await; - let teams = &mut TEAMS.write().unwrap(); - (!teams.contains_key(&username)).or_forbidden("taken username")?; - _ = teams.insert(username, SolvedPuzzles::new()); - Ok(String::from("helo, mehet!")) -} - -/// set `puzzle_id`'s a `solution` and `value` with `ADMIN_PASSWORD` -#[post("/api/set_solution")] -pub async fn set_solution( - puzzle_solutions: PuzzleSolutions, - password: String, -) -> Result { - _ = backup_state().await; - // submitting as admin - (*ADMIN_PASSWORD == password).or_unauthorized("incorrect password for APOLLO_MESTER")?; - - let puzzles_state = &mut PUZZLES.write().unwrap(); - puzzle_solutions - .keys() - .any(|new_k| !puzzles_state.contains_key(new_k)) - .or_forbidden("one of the puzzles already set")?; - - puzzles_state.extend(puzzle_solutions); - - Ok(String::from("beallitottam a megoldast")) -} - -/// submit a solution as a team -#[post("/api/submit")] -pub async fn submit_solution( - username: String, - puzzle_id: PuzzleId, - solution: PuzzleSolution, -) -> Result { - _ = backup_state().await; - let teams = &mut TEAMS.write().unwrap(); - let team_state = teams - .get_mut(&username) - .or_forbidden("no such team in the competition, join first")?; +pub use logic::ensure_admin_env_vars; +#[cfg(feature = "server")] +mod logic; - let puzzles = &mut PUZZLES.read().unwrap(); - if solution - == *puzzles - .get(&puzzle_id) - .or_not_found("no such puzzle")? - .solution - { - team_state - .insert(puzzle_id) - .or_forbidden("already solved this puzzle")?; - Ok(String::from("oke, megoldottad, elmentettem!")) - } else { - HttpError::forbidden("incorrect solution")? - } -} +pub mod endpoints; diff --git a/src/backend/endpoints.rs b/src/backend/endpoints.rs new file mode 100644 index 0000000..7181640 --- /dev/null +++ b/src/backend/endpoints.rs @@ -0,0 +1,80 @@ +#[cfg(feature = "server")] +use super::logic::*; +use super::models::*; +use dioxus::fullstack::{CborEncoding, Streaming}; +use dioxus::prelude::*; + +#[get("/api/event_title")] +pub async fn event_title() -> Result { + Ok(EVENT_TITLE.clone()?) +} + +/// streams current progress of the teams and existing puzzles with their values +#[get("/api/state")] +pub async fn state_stream() -> Result> { + Ok(Streaming::spawn(|tx| async move { + while tx.unbounded_send(get_game_state().await).is_ok() { + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + })) +} + +/// join the competition as a contestant team +#[post("/api/join")] +pub async fn join(username: String) -> Result { + _ = backup_state().await; + let teams = &mut TEAMS.write().unwrap(); + (!teams.contains_key(&username)).or_forbidden("taken username")?; + _ = teams.insert(username, SolvedPuzzles::new()); + Ok(String::from("helo, mehet!")) +} + +/// set `puzzle_id`'s a `solution` and `value` with `ADMIN_PASSWORD` +#[post("/api/set_solution")] +pub async fn set_solution( + puzzle_solutions: PuzzleSolutions, + password: String, +) -> Result { + _ = backup_state().await; + // submitting as admin + (*ADMIN_PASSWORD == password).or_unauthorized("incorrect password for APOLLO_MESTER")?; + + let puzzles_state = &mut PUZZLES.write().unwrap(); + puzzle_solutions + .keys() + .any(|new_k| !puzzles_state.contains_key(new_k)) + .or_forbidden("one of the puzzles already set")?; + + puzzles_state.extend(puzzle_solutions); + + Ok(String::from("beallitottam a megoldast")) +} + +/// submit a solution as a team +#[post("/api/submit")] +pub async fn submit_solution( + username: String, + puzzle_id: PuzzleId, + solution: PuzzleSolution, +) -> Result { + _ = backup_state().await; + let teams = &mut TEAMS.write().unwrap(); + let team_state = teams + .get_mut(&username) + .or_forbidden("no such team in the competition, join first")?; + + let puzzles = &mut PUZZLES.read().unwrap(); + if solution + == *puzzles + .get(&puzzle_id) + .or_not_found("no such puzzle")? + .solution + { + team_state + .insert(puzzle_id) + .or_forbidden("already solved this puzzle")?; + Ok(String::from("oke, megoldottad, elmentettem!")) + } else { + HttpError::forbidden("incorrect solution")? + } +} diff --git a/src/backend/logic.rs b/src/backend/logic.rs new file mode 100644 index 0000000..6b5f509 --- /dev/null +++ b/src/backend/logic.rs @@ -0,0 +1,59 @@ +use super::models::*; +use dioxus::prelude::*; +use std::sync::{LazyLock, RwLock}; +use std::{env, process}; + +pub(super) static PUZZLES: LazyLock> = + LazyLock::new(|| RwLock::new(PuzzleSolutions::new())); + +pub(super) static TEAMS: LazyLock> = + LazyLock::new(|| RwLock::new(TeamsState::new())); + +/// without `name`, the app won't run +fn ensure_env_var(key: &str) -> String { + let Ok(value) = env::var(key) else { + error!("{key:?} env var not set, can't proceed"); + process::exit(1); + }; + if value.is_empty() { + error!("{key:?} env var empty, can't proceed"); + process::exit(1); + } + value +} + +/// # exits with 1 +/// if necessary admin env vars aren't set +pub fn ensure_admin_env_vars() { + _ = LazyLock::force(&ADMIN_PASSWORD); +} + +pub(super) static ADMIN_PASSWORD: LazyLock = + LazyLock::new(|| ensure_env_var("APOLLO_MESTER_JELSZO")); +pub(super) static EVENT_TITLE: LazyLock> = + LazyLock::new(|| env::var("APOLLO_EVENT_TITLE")); + +pub(super) async fn get_game_state() -> (TeamsState, PuzzlesExisting) { + let existing_puzzles = PUZZLES + .read() + .unwrap() + .clone() + .into_iter() + .map(|(id, sol)| (id, sol.value)) + .collect(); + (TEAMS.read().unwrap().clone(), existing_puzzles) +} + +/// just save a copy of the `PUZZLES` and `TEAMS` state to disk into a `cbor` file +/// TODO: add basic encryption using `ADMIN_PASSWORD` +pub(super) async fn backup_state() -> Result<()> { + let teams_state = TEAMS.read().unwrap().clone(); + let puzzles_state = PUZZLES.read().unwrap().clone(); + let mut buf = vec![]; + ciborium::into_writer(&(teams_state, puzzles_state), &mut buf) + .inspect_err(|e| error!("couldn't serialize into cbor: {e}"))?; + tokio::fs::write("apollo-state.cbor", buf) + .await + .inspect_err(|e| error!("couldn't write state to file: {e}"))?; + Ok(()) +} From d26a9ebfe492f7ca58390d0720864bce6015b268 Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 6 Dec 2025 01:19:21 +0100 Subject: [PATCH 17/70] feat(client): click to fullscreen table --- assets/main.css | 25 +++++++-- src/app.rs | 140 +++++++++++++++++++++++++----------------------- tailwind.css | 1 + 3 files changed, 93 insertions(+), 73 deletions(-) diff --git a/assets/main.css b/assets/main.css index d926cc9..6a3a1f1 100644 --- a/assets/main.css +++ b/assets/main.css @@ -1,6 +1,20 @@ -/* -colors -*/ +.normal .others-container { + display: block; +} + +.table-only .others-container { + display: none !important; +} + +.table-only .table-container table { + position: fixed; + margin: auto; + inset: 0; + width: 80vw; + height: 80vh; + overflow: hidden; +} + .popup { position: fixed; bottom: 20px; @@ -35,8 +49,8 @@ colors } body { - background-color: #0f1116; - color: #ffffff; + background-color: var(--bg); + color: var(--light); font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; margin: 20px; } @@ -48,6 +62,7 @@ td, tr { border: solid 2px var(--middle); border-collapse: collapse; + color: var(--light); } th { diff --git a/src/app.rs b/src/app.rs index 92dc442..e79ee21 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,6 +36,7 @@ pub fn App() -> Element { let mut puzzles = use_signal(|| PuzzlesExisting::new()); let mut message = use_signal(|| None::); let mut title = use_signal(|| None::); + let mut is_fullscreen = use_signal(|| false); trace!("variables inited"); use_future(move || async move { @@ -75,6 +76,12 @@ pub fn App() -> Element { dioxus::Ok(()) }); + let toggle_fullscreen = move |_| { + trace!("fullscreen toggle called"); + let fullscreen_current = *is_fullscreen.read(); + is_fullscreen.set(!fullscreen_current); + }; + // Handle join/submit button click // TODO this is very ugly function thing make it better let handle_action = move |_| async move { @@ -176,97 +183,94 @@ pub fn App() -> Element { document::Link { rel: "stylesheet", href: MAIN_CSS } document::Link { rel: "stylesheet", href: TAILWIND_CSS } - div { class: "container", - // TODO get from envpoint - // h1 { class: "mb-4 font-bold text-lg", "EVENT TITLE PLACEHOLDER" } - h1 { class: "mb-4 font-bold text-lg", - if let Some(t) = &*title.read() { - "{t}", - } else { - "Betöltés..." - } - } - - // Input section - div { class: "input-section", - if !*joined.read() { - // Join form - input { class: INPUT, - r#type: "text", - placeholder: "Username", - value: "{username}", - oninput: move |evt| username.set(evt.value()) + div { class: if *is_fullscreen.read() { "table-only" } else { "normal" }, + div { class: "others-container", + h1 { class: "mb-4 font-bold text-lg", + if let Some(t) = &*title.read() { + "{t}", + } else { + "Betöltés..." } + } - if *show_password_prompt.read() { - input { class: "ml-4 {INPUT}", - r#type: "password", - placeholder: "Admin Password", - value: "{password}", - oninput: move |evt| password.set(evt.value()) + // Input section + div { class: "input-section", + if !*joined.read() { + // Join form + input { class: INPUT, + r#type: "text", + placeholder: "Username", + value: "{username}", + oninput: move |evt| username.set(evt.value()) } - } - button { class: BUTTON, onclick: handle_action, "Join" } - } else { - // Submit form - input { class: INPUT, - r#type: "text", - placeholder: "Puzzle ID", - value: "{puzzle_id}", - oninput: move |evt| puzzle_id.set(evt.value()) - } + if *show_password_prompt.read() { + input { class: "ml-4 {INPUT}", + r#type: "password", + placeholder: "Admin Password", + value: "{password}", + oninput: move |evt| password.set(evt.value()) + } + } - input { class: "ml-4 {INPUT}", - r#type: "text", - placeholder: "Solution", - value: "{puzzle_solution}", - oninput: move |evt| puzzle_solution.set(evt.value()) - } + button { class: BUTTON, onclick: handle_action, "Join" } + } else { + // Submit form + input { class: INPUT, + r#type: "text", + placeholder: "Puzzle ID", + value: "{puzzle_id}", + oninput: move |evt| puzzle_id.set(evt.value()) + } - if *is_admin.read() { input { class: "ml-4 {INPUT}", r#type: "text", - placeholder: "Puzlle Value", - value: "{puzzle_value}", - oninput: move |evt| puzzle_value.set(evt.value()) + placeholder: "Solution", + value: "{puzzle_solution}", + oninput: move |evt| puzzle_solution.set(evt.value()) } - } - if *is_admin.read() { - input { class: "ml-4 {INPUT}", - r#type: "password", - placeholder: "Admin Password", - value: "{password}", - oninput: move |evt| password.set(evt.value()) + if *is_admin.read() { + input { class: "ml-4 {INPUT}", + r#type: "text", + placeholder: "Puzlle Value", + value: "{puzzle_value}", + oninput: move |evt| puzzle_value.set(evt.value()) + } } - } - button { class: BUTTON, onclick: handle_action, "Send" } + if *is_admin.read() { + input { class: "ml-4 {INPUT}", + r#type: "password", + placeholder: "Admin Password", + value: "{password}", + oninput: move |evt| password.set(evt.value()) + } + } + + button { class: BUTTON, onclick: handle_action, "Send" } + } } - } - // Message display - // if !message.read().is_empty() { - // div { class: "message", "{message}" } - // } - if let Some(msg) = &*message.read() { - div { - class: "popup", - "{msg}" + // Message popup + if let Some(msg) = &*message.read() { + div { + class: "popup", + "{msg}" + } } } - // Teams and puzzles table div { class: "table-container", table { class: "mt-5", + onclick: toggle_fullscreen, thead { tr { th { class: "text-left pl-2", "." } for (id, value) in puzzles.read().iter() { th { Tooltip { - TooltipTrigger { "Puzzle {id}" } + TooltipTrigger { class: "text-(--light)", "Puzzle {id}" } TooltipContent { side: ContentSide::Top, align: ContentAlign::Center, @@ -282,9 +286,9 @@ pub fn App() -> Element { tbody { for (team_name, solved) in teams_state.read().iter() { tr { - td { class: "text-left pl-2 bg-(--dark2)", "{team_name}" } + td { class: "text-left pl-2 text-(--light) bg-(--dark2)", "{team_name}" } for (puzzle_id, _puzzle) in puzzles.read().iter() { - td { class: "bg-(--dark) text-center", + td { class: "text-(--light) bg-(--dark) text-center text-[30px] font-[900]", if solved.contains(puzzle_id) { "X" } else { diff --git a/tailwind.css b/tailwind.css index ee4ecfe..3cd4b02 100644 --- a/tailwind.css +++ b/tailwind.css @@ -2,6 +2,7 @@ @theme { --text-lg: 3rem; + --bg: #0f1116; --dark: #13293d; --dark2: #006494; --middle: #247ba0; From c8610250323aa419e07dd22824c0ddf2b4a82097 Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 6 Dec 2025 01:34:02 +0100 Subject: [PATCH 18/70] feat(client): (probably) all messages and placeholders in hungarian --- src/app.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/app.rs b/src/app.rs index e79ee21..14190b6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -100,7 +100,7 @@ pub fn App() -> Element { // If password is empty, don't proceed yet if password_current.is_empty() { - message.set(Some("Please enter admin password".to_string())); + message.set(Some("Adja meg az admin jelszót".to_string())); return; } joined.set(true); @@ -199,7 +199,7 @@ pub fn App() -> Element { // Join form input { class: INPUT, r#type: "text", - placeholder: "Username", + placeholder: "Csapatnév", value: "{username}", oninput: move |evt| username.set(evt.value()) } @@ -207,13 +207,13 @@ pub fn App() -> Element { if *show_password_prompt.read() { input { class: "ml-4 {INPUT}", r#type: "password", - placeholder: "Admin Password", + placeholder: "Admin jelszó", value: "{password}", oninput: move |evt| password.set(evt.value()) } } - button { class: BUTTON, onclick: handle_action, "Join" } + button { class: BUTTON, onclick: handle_action, "Belépés" } } else { // Submit form input { class: INPUT, @@ -225,7 +225,7 @@ pub fn App() -> Element { input { class: "ml-4 {INPUT}", r#type: "text", - placeholder: "Solution", + placeholder: "Megoldás", value: "{puzzle_solution}", oninput: move |evt| puzzle_solution.set(evt.value()) } @@ -233,22 +233,23 @@ pub fn App() -> Element { if *is_admin.read() { input { class: "ml-4 {INPUT}", r#type: "text", - placeholder: "Puzlle Value", + placeholder: "Érték/Nyeremény", value: "{puzzle_value}", oninput: move |evt| puzzle_value.set(evt.value()) } - } - if *is_admin.read() { input { class: "ml-4 {INPUT}", r#type: "password", - placeholder: "Admin Password", + placeholder: "Admin jelszó", value: "{password}", oninput: move |evt| password.set(evt.value()) } + + button { class: BUTTON, onclick: handle_action, "Beállítás" } + } else { + button { class: BUTTON, onclick: handle_action, "Küldés" } } - button { class: BUTTON, onclick: handle_action, "Send" } } } From 2dba63d61fa3748065153158bf043c22946a00d2 Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 6 Dec 2025 02:02:23 +0100 Subject: [PATCH 19/70] misc(client): move and rescale popup message --- assets/main.css | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/assets/main.css b/assets/main.css index 6a3a1f1..2f91589 100644 --- a/assets/main.css +++ b/assets/main.css @@ -16,35 +16,37 @@ } .popup { + width: auto; + height: auto; position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); + top: 30px; + right: 30px; background: #333; color: white; - padding: 12px 20px; + 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 4.7s forwards; + fadeout 0.3s ease-in 2.7s forwards; } @keyframes fadein { from { opacity: 0; - transform: translateX(-50%) translateY(10px); + transform: translateY(-10px); } to { opacity: 1; - transform: translateX(-50%) translateY(0); + transform: translateY(0); } } @keyframes fadeout { to { opacity: 0; - transform: translateX(-50%) translateY(10px); + transform: translateY(-10px); } } From 13f1329e424ea82301ec5f1d1ba1a150df4a5ea0 Mon Sep 17 00:00:00 2001 From: csboo Date: Mon, 8 Dec 2025 03:03:11 +0100 Subject: [PATCH 20/70] feat(client): set puzzles from csv --- Cargo.toml | 5 ++-- src/app.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b0c335e..1786713 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ edition = "2024" [dependencies] ciborium = "0.2.2" +csv = "1.4.0" dioxus = { version = "0.7.1", features = ["fullstack"] } dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } gloo-timers = { version = "0.3.0", features = ["futures"] } @@ -16,6 +17,6 @@ tokio = { version = "1.48.0", optional = true } [features] default = ["web"] web = ["dioxus/web"] -desktop = ["dioxus/desktop"] -mobile = ["dioxus/mobile"] +# desktop = ["dioxus/desktop"] +# mobile = ["dioxus/mobile"] server = ["dioxus/server", "dep:tokio"] diff --git a/src/app.rs b/src/app.rs index 14190b6..7e5d8c4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use dioxus::fullstack::serde; use dioxus::prelude::*; use dioxus_primitives::{ContentAlign, ContentSide}; @@ -11,6 +12,7 @@ const MAIN_CSS: Asset = asset!("/assets/main.css"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); const BUTTON: &str = "ml-4 w-30 px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; const INPUT: &str = "w-50 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"; +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"; // TODO could be handeled in much better ways async fn check_admin_username(username: String) -> Result { @@ -19,6 +21,37 @@ async fn check_admin_username(username: String) -> Result { Ok(username == admin_username) } +#[derive(Debug, serde::Deserialize)] +#[serde(crate = "dioxus::fullstack::serde")] +struct PuzzleCsvRow { + id: String, + solution: String, + value: u32, +} + +use csv::ReaderBuilder; +fn parse_puzzle_csv(csv_text: &str) -> PuzzleSolutions { + let mut rdr = ReaderBuilder::new() + .has_headers(true) + .from_reader(csv_text.as_bytes()); + + let mut puzzles = PuzzleSolutions::new(); + + for result in rdr.deserialize::() { + let row = result.expect("invalid csv row"); + + puzzles.insert( + row.id, + Puzzle { + solution: row.solution, + value: row.value, + }, + ); + } + + puzzles +} + #[component] pub fn App() -> Element { trace!("kicking off app"); @@ -37,6 +70,7 @@ pub fn App() -> Element { let mut message = use_signal(|| None::); let mut title = use_signal(|| None::); let mut is_fullscreen = use_signal(|| false); + let mut parsed_puzzles = use_signal(|| PuzzleSolutions::new()); trace!("variables inited"); use_future(move || async move { @@ -76,6 +110,19 @@ pub fn App() -> Element { dioxus::Ok(()) }); + let handle_csv = move |evt: Event| async move { + let text = evt + .files() + .iter() + .next() + .unwrap() + .read_string() + .await + .unwrap(); + + parsed_puzzles.set(parse_puzzle_csv(&text)); + }; + let toggle_fullscreen = move |_| { trace!("fullscreen toggle called"); let fullscreen_current = *is_fullscreen.read(); @@ -130,15 +177,19 @@ pub fn App() -> Element { // &value_current.is_empty() // ); if admin { - let value_current_num = value_current.parse::().unwrap(); match crate::backend::endpoints::set_solution( - PuzzleSolutions::from([( - puzzle_current, - Puzzle { - value: value_current_num, - solution: solution_current, - }, - )]), + if parsed_puzzles.read().is_empty() { + let value_current_num = value_current.parse::().unwrap(); + PuzzleSolutions::from([( + puzzle_current, + Puzzle { + value: value_current_num, + solution: solution_current, + }, + )]) + } else { + parsed_puzzles.read().clone() + }, password_current, ) .await @@ -245,6 +296,12 @@ pub fn App() -> Element { oninput: move |evt| password.set(evt.value()) } + input { class: "ml-4 {CSV_INPUT}", + r#type: "file", + r#accept: ".csv", + onchange: handle_csv, + } + button { class: BUTTON, onclick: handle_action, "Beállítás" } } else { button { class: BUTTON, onclick: handle_action, "Küldés" } From b1a5b25762c581571850cc46087f720308a9f0d5 Mon Sep 17 00:00:00 2001 From: csboo Date: Mon, 8 Dec 2025 11:48:48 +0100 Subject: [PATCH 21/70] refactor(client): separate into crates, cleaner code --- src/app.rs | 214 ++++++++++++--------------------------------- src/app/actions.rs | 115 ++++++++++++++++++++++++ src/app/models.rs | 7 ++ src/app/utils.rs | 25 ++++++ 4 files changed, 203 insertions(+), 158 deletions(-) create mode 100644 src/app/actions.rs create mode 100644 src/app/models.rs create mode 100644 src/app/utils.rs diff --git a/src/app.rs b/src/app.rs index 7e5d8c4..459df68 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,13 @@ -use dioxus::fullstack::serde; use dioxus::prelude::*; use dioxus_primitives::{ContentAlign, ContentSide}; +mod actions; +mod models; +mod utils; + use crate::{ - backend::models::{Puzzle, PuzzleSolutions, PuzzlesExisting, TeamsState}, + app::{models::AuthState, utils::parse_puzzle_csv}, + backend::models::{PuzzleSolutions, PuzzlesExisting, TeamsState}, components::tooltip::*, }; @@ -14,57 +18,21 @@ const BUTTON: &str = "ml-4 w-30 px-3 py-2 rounded-lg border border-(--dark2) bg- const INPUT: &str = "w-50 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"; 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"; -// TODO could be handeled in much better ways -async fn check_admin_username(username: String) -> Result { - // use std::env; - let admin_username = "jani"; - Ok(username == admin_username) -} - -#[derive(Debug, serde::Deserialize)] -#[serde(crate = "dioxus::fullstack::serde")] -struct PuzzleCsvRow { - id: String, - solution: String, - value: u32, -} - -use csv::ReaderBuilder; -fn parse_puzzle_csv(csv_text: &str) -> PuzzleSolutions { - let mut rdr = ReaderBuilder::new() - .has_headers(true) - .from_reader(csv_text.as_bytes()); - - let mut puzzles = PuzzleSolutions::new(); - - for result in rdr.deserialize::() { - let row = result.expect("invalid csv row"); - - puzzles.insert( - row.id, - Puzzle { - solution: row.solution, - value: row.value, - }, - ); - } - - puzzles -} - #[component] pub fn App() -> Element { trace!("kicking off app"); // State management variables trace!("initing variables"); - let mut username = use_signal(|| String::new()); - let mut password = use_signal(|| String::new()); let mut puzzle_id = use_signal(|| String::new()); let mut puzzle_solution = use_signal(|| String::new()); let mut puzzle_value = use_signal(|| String::new()); - let mut joined = use_signal(|| false); - let mut is_admin = use_signal(|| false); - let mut show_password_prompt = use_signal(|| false); + let mut auth = use_signal(|| AuthState { + username: String::new(), + password: String::new(), + joined: false, + is_admin: false, + show_password_prompt: false, + }); let mut teams_state = use_signal(|| TeamsState::new()); let mut puzzles = use_signal(|| PuzzlesExisting::new()); let mut message = use_signal(|| None::); @@ -73,6 +41,7 @@ pub fn App() -> Element { let mut parsed_puzzles = use_signal(|| PuzzleSolutions::new()); trace!("variables inited"); + // side effect handlers use_future(move || async move { title.set( crate::backend::endpoints::event_title() @@ -82,17 +51,6 @@ pub fn App() -> Element { ); }); - use_effect(move || { - if message.read().is_some() { - // hide after 5 seconds - // let message = message.clone(); - spawn(async move { - gloo_timers::future::sleep(std::time::Duration::from_secs(5)).await; - message.set(None); - }); - } - }); - use_future(move || async move { // Call the stream endpoint to get a stream of events trace!("calling state_stream"); @@ -110,6 +68,18 @@ pub fn App() -> Element { dioxus::Ok(()) }); + use_effect(move || { + if message.read().is_some() { + // hide after 5 seconds + // let message = message.clone(); + spawn(async move { + gloo_timers::future::sleep(std::time::Duration::from_secs(5)).await; + message.set(None); + }); + } + }); + + // action handlers let handle_csv = move |evt: Event| async move { let text = evt .files() @@ -129,103 +99,31 @@ pub fn App() -> Element { is_fullscreen.set(!fullscreen_current); }; - // Handle join/submit button click - // TODO this is very ugly function thing make it better let handle_action = move |_| async move { trace!("action handler called"); - let username_current = username.read().clone(); - let password_current = password.read().clone(); - let is_joined = *joined.read(); - let admin = *is_admin.read(); - - if !is_joined { - // Check if username is admin before joining - if let Ok(is_admin_user) = check_admin_username(username_current.clone()).await { - if is_admin_user { - is_admin.set(true); - show_password_prompt.set(true); - - // If password is empty, don't proceed yet - if password_current.is_empty() { - message.set(Some("Adja meg az admin jelszót".to_string())); - return; - } - joined.set(true); - return; - } - }; - - match crate::backend::endpoints::join(username_current.clone()).await { - Ok(msg) => { - message.set(Some(msg.clone())); - joined.set(true); - password.set(String::new()); - show_password_prompt.set(false); - } - Err(e) => { - message.set(Some(format!("Error: {}", e))); - } - } + let username_current = auth.read().username.clone(); + let password_current = auth.read().password.clone(); + + if !auth.read().joined { + actions::handle_join(username_current, password_current, &mut auth, &mut message).await; + } else if auth.read().is_admin { + actions::handle_admin_submit( + &mut puzzle_id, + &mut puzzle_value, + &mut puzzle_solution, + &parsed_puzzles, + password_current, + &mut message, + ) + .await; } else { - // Submit solution - call backend function directly - let puzzle_current = puzzle_id.read().clone(); - let solution_current = puzzle_solution.read().clone(); - let value_current = puzzle_value.read().clone(); - // trace!( - // "value is '{}' is_empty: '{}'", - // &value_current, - // &value_current.is_empty() - // ); - if admin { - match crate::backend::endpoints::set_solution( - if parsed_puzzles.read().is_empty() { - let value_current_num = value_current.parse::().unwrap(); - PuzzleSolutions::from([( - puzzle_current, - Puzzle { - value: value_current_num, - solution: solution_current, - }, - )]) - } else { - parsed_puzzles.read().clone() - }, - password_current, - ) - .await - { - Ok(msg) => { - message.set(Some(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) => { - message.set(Some(format!("Error: {}", e))); - } - } - return; - } - - match crate::backend::endpoints::submit_solution( - username_current.clone(), - puzzle_current, - solution_current, + actions::handle_user_submit( + &mut puzzle_id, + &mut puzzle_solution, + username_current, + &mut message, ) - .await - { - Ok(msg) => { - message.set(Some(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) => { - message.set(Some(format!("Error: {}", e))); - } - } + .await; } }; @@ -246,21 +144,21 @@ pub fn App() -> Element { // Input section div { class: "input-section", - if !*joined.read() { + if !auth.read().joined { // Join form input { class: INPUT, r#type: "text", placeholder: "Csapatnév", - value: "{username}", - oninput: move |evt| username.set(evt.value()) + value: "{auth.read().username}", + oninput: move |evt| auth.write().username = evt.value() } - if *show_password_prompt.read() { + if auth.read().show_password_prompt { input { class: "ml-4 {INPUT}", r#type: "password", placeholder: "Admin jelszó", - value: "{password}", - oninput: move |evt| password.set(evt.value()) + value: "{auth.read().password}", + oninput: move |evt| auth.write().password = evt.value() } } @@ -281,7 +179,7 @@ pub fn App() -> Element { oninput: move |evt| puzzle_solution.set(evt.value()) } - if *is_admin.read() { + if auth.read().is_admin { input { class: "ml-4 {INPUT}", r#type: "text", placeholder: "Érték/Nyeremény", @@ -292,8 +190,8 @@ pub fn App() -> Element { input { class: "ml-4 {INPUT}", r#type: "password", placeholder: "Admin jelszó", - value: "{password}", - oninput: move |evt| password.set(evt.value()) + value: "{auth.read().password}", + oninput: move |evt| auth.write().password = evt.value() } input { class: "ml-4 {CSV_INPUT}", diff --git a/src/app/actions.rs b/src/app/actions.rs new file mode 100644 index 0000000..512e14e --- /dev/null +++ b/src/app/actions.rs @@ -0,0 +1,115 @@ +use dioxus::{prelude::*, signals::Signal}; + +use crate::{ + app::models::AuthState, + backend::models::{Puzzle, PuzzleSolutions}, +}; + +// TODO could be handeled in much better ways +async fn check_admin_username(username: String) -> Result { + // use std::env; + let admin_username = "jani"; + Ok(username == admin_username) +} + +pub async fn handle_join( + username_current: String, + password_current: String, + auth: &mut Signal, + message: &mut Signal>, +) { + if let Ok(is_admin_user) = check_admin_username(username_current.clone()).await { + if is_admin_user { + auth.write().is_admin = true; + auth.write().show_password_prompt = true; + + // If password is empty, don't proceed yet + if password_current.is_empty() { + message.set(Some("Adja meg az admin jelszót".to_string())); + return; + } + auth.write().joined = true; + return; + } + }; + + match crate::backend::endpoints::join(username_current.clone()).await { + Ok(msg) => { + message.set(Some(msg.clone())); + auth.write().joined = true; + auth.write().password = String::new(); + auth.write().show_password_prompt = false; + } + Err(e) => { + message.set(Some(format!("Error: {}", e))); + } + } +} + +pub async fn handle_user_submit( + puzzle_id: &mut Signal, + puzzle_solution: &mut Signal, + username_current: String, + message: &mut Signal>, +) { + let puzzle_current = puzzle_id.read().clone(); + let solution_current = puzzle_solution.read().clone(); + match crate::backend::endpoints::submit_solution( + username_current, + puzzle_current, + solution_current, + ) + .await + { + Ok(msg) => { + message.set(Some(msg)); + puzzle_id.set(String::new()); + puzzle_solution.set(String::new()); + } + Err(e) => { + message.set(Some(format!("Error: {}", e))); + } + } +} + +pub async fn handle_admin_submit( + puzzle_id: &mut Signal, + puzzle_value: &mut Signal, + puzzle_solution: &mut Signal, + parsed_puzzles: &Signal, + password_current: String, + message: &mut Signal>, +) { + // Submit solution - call backend function directly + let puzzle_current = puzzle_id.read().clone(); + let solution_current = puzzle_solution.read().clone(); + let value_current = puzzle_value.read().clone(); + match crate::backend::endpoints::set_solution( + if parsed_puzzles.read().is_empty() { + let value_current_num = value_current.parse::().unwrap(); // TODO WARN unwrap + PuzzleSolutions::from([( + puzzle_current, + Puzzle { + value: value_current_num, + solution: solution_current, + }, + )]) + } else { + parsed_puzzles.read().clone() + }, + password_current, + ) + .await + { + Ok(msg) => { + message.set(Some(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) => { + message.set(Some(format!("Error: {}", e))); + } + } +} diff --git a/src/app/models.rs b/src/app/models.rs new file mode 100644 index 0000000..30ae7b8 --- /dev/null +++ b/src/app/models.rs @@ -0,0 +1,7 @@ +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, +} diff --git a/src/app/utils.rs b/src/app/utils.rs new file mode 100644 index 0000000..25ebaaa --- /dev/null +++ b/src/app/utils.rs @@ -0,0 +1,25 @@ +use csv::ReaderBuilder; +use dioxus::prelude::*; + +use crate::backend::models::{Puzzle, PuzzleId, PuzzleSolutions}; + +pub fn parse_puzzle_csv(csv_text: &str) -> PuzzleSolutions { + let mut rdr = ReaderBuilder::new() + .has_headers(true) + .from_reader(csv_text.as_bytes()); + + let mut puzzles = PuzzleSolutions::new(); + + for result in rdr.deserialize::<(PuzzleId, Puzzle)>() { + match result { + Ok((id, puzzle)) => { + puzzles.insert(id, puzzle); + } + Err(e) => { + warn!("Skipping invalid CSV row: {}", e); + } + } + } + + puzzles +} From 6b33f94fdb9a13a526de363206b8f43b43857473 Mon Sep 17 00:00:00 2001 From: csboo Date: Mon, 8 Dec 2025 13:40:01 +0100 Subject: [PATCH 22/70] fix(client): dont cut hover popup on fullscreen --- assets/main.css | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/main.css b/assets/main.css index 2f91589..33f2a48 100644 --- a/assets/main.css +++ b/assets/main.css @@ -12,7 +12,6 @@ inset: 0; width: 80vw; height: 80vh; - overflow: hidden; } .popup { From d7cb59b68f1110ccf3e93d7a83a81b98922f94b9 Mon Sep 17 00:00:00 2001 From: csboo Date: Mon, 8 Dec 2025 15:10:30 +0100 Subject: [PATCH 23/70] fix(client): fix csv parser --- src/app/utils.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app/utils.rs b/src/app/utils.rs index 25ebaaa..3c813c3 100644 --- a/src/app/utils.rs +++ b/src/app/utils.rs @@ -1,7 +1,7 @@ use csv::ReaderBuilder; use dioxus::prelude::*; -use crate::backend::models::{Puzzle, PuzzleId, PuzzleSolutions}; +use crate::backend::models::{Puzzle, PuzzleSolutions}; pub fn parse_puzzle_csv(csv_text: &str) -> PuzzleSolutions { let mut rdr = ReaderBuilder::new() @@ -10,15 +10,19 @@ pub fn parse_puzzle_csv(csv_text: &str) -> PuzzleSolutions { let mut puzzles = PuzzleSolutions::new(); - for result in rdr.deserialize::<(PuzzleId, Puzzle)>() { - match result { - Ok((id, puzzle)) => { - puzzles.insert(id, puzzle); - } + for result in rdr.records() { + let record = match result { + Ok(r) => r, Err(e) => { warn!("Skipping invalid CSV row: {}", e); + continue; } - } + }; + let id = record.get(0).unwrap().to_string(); + let solution = record.get(1).unwrap().to_string(); + let value: u32 = record.get(2).unwrap().parse().unwrap(); + + puzzles.insert(id, Puzzle { solution, value }); } puzzles From 23f17a374e29e572893ec69f7fa042cb641c0004 Mon Sep 17 00:00:00 2001 From: csboo Date: Mon, 8 Dec 2025 15:13:41 +0100 Subject: [PATCH 24/70] refactor(client): move ScoreTabel to component, code cleanup --- src/app.rs | 59 ++++++++--------------------------- src/components/mod.rs | 1 + src/components/score_table.rs | 55 ++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 46 deletions(-) create mode 100644 src/components/score_table.rs diff --git a/src/app.rs b/src/app.rs index 459df68..a19952b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,4 @@ use dioxus::prelude::*; -use dioxus_primitives::{ContentAlign, ContentSide}; mod actions; mod models; @@ -8,7 +7,7 @@ mod utils; use crate::{ app::{models::AuthState, utils::parse_puzzle_csv}, backend::models::{PuzzleSolutions, PuzzlesExisting, TeamsState}, - components::tooltip::*, + components::score_table::ScoreTable, }; const FAVICON: Asset = asset!("/assets/favicon.ico"); @@ -33,6 +32,7 @@ pub fn App() -> Element { is_admin: false, show_password_prompt: false, }); + let auth_current = auth.read(); let mut teams_state = use_signal(|| TeamsState::new()); let mut puzzles = use_signal(|| PuzzlesExisting::new()); let mut message = use_signal(|| None::); @@ -71,7 +71,6 @@ pub fn App() -> Element { use_effect(move || { if message.read().is_some() { // hide after 5 seconds - // let message = message.clone(); spawn(async move { gloo_timers::future::sleep(std::time::Duration::from_secs(5)).await; message.set(None); @@ -144,20 +143,20 @@ pub fn App() -> Element { // Input section div { class: "input-section", - if !auth.read().joined { + if !auth_current.joined { // Join form input { class: INPUT, r#type: "text", placeholder: "Csapatnév", - value: "{auth.read().username}", + value: "{auth_current.username}", oninput: move |evt| auth.write().username = evt.value() } - if auth.read().show_password_prompt { + if auth_current.show_password_prompt { input { class: "ml-4 {INPUT}", r#type: "password", placeholder: "Admin jelszó", - value: "{auth.read().password}", + value: "{auth_current.password}", oninput: move |evt| auth.write().password = evt.value() } } @@ -179,7 +178,7 @@ pub fn App() -> Element { oninput: move |evt| puzzle_solution.set(evt.value()) } - if auth.read().is_admin { + if auth_current.is_admin { input { class: "ml-4 {INPUT}", r#type: "text", placeholder: "Érték/Nyeremény", @@ -190,7 +189,7 @@ pub fn App() -> Element { input { class: "ml-4 {INPUT}", r#type: "password", placeholder: "Admin jelszó", - value: "{auth.read().password}", + value: "{auth_current.password}", oninput: move |evt| auth.write().password = evt.value() } @@ -217,44 +216,12 @@ pub fn App() -> Element { } } // Teams and puzzles table + div { class: "table-container", - table { class: "mt-5", - onclick: toggle_fullscreen, - thead { - tr { - th { class: "text-left pl-2", "." } - for (id, value) in puzzles.read().iter() { - th { - Tooltip { - TooltipTrigger { class: "text-(--light)", "Puzzle {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 { - "" - } - } - } - } - } - } + ScoreTable { + puzzles: puzzles, + teams_state: teams_state, + toggle_fullscreen: toggle_fullscreen, } } } diff --git a/src/components/mod.rs b/src/components/mod.rs index dd4d440..7896974 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,2 +1,3 @@ // AUTOGENERTED Components module +pub mod score_table; pub mod tooltip; diff --git a/src/components/score_table.rs b/src/components/score_table.rs new file mode 100644 index 0000000..a0025da --- /dev/null +++ b/src/components/score_table.rs @@ -0,0 +1,55 @@ +use dioxus::prelude::*; +use dioxus_primitives::{ContentAlign, ContentSide}; + +use crate::{ + backend::models::{PuzzlesExisting, TeamsState}, + components::tooltip::*, +}; + +#[component] +pub fn ScoreTable( + puzzles: Signal, + teams_state: Signal, + toggle_fullscreen: EventHandler, +) -> Element { + rsx! { + table { class: "mt-5", + onclick: toggle_fullscreen, + thead { + tr { + th { class: "text-left pl-2", "." } + for (id, value) in puzzles.read().iter() { + th { + Tooltip { + TooltipTrigger { class: "text-(--light)", "Puzzle {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 { + "" + } + } + } + } + } + } + } + } +} From 9440e721f3a7690663937b66aa2815f9b5b8f6dc Mon Sep 17 00:00:00 2001 From: csboo Date: Mon, 8 Dec 2025 15:44:41 +0100 Subject: [PATCH 25/70] merge(client): backend latest --- .gitignore | 3 +- Cargo.toml | 18 +++-- Makefile | 23 ++++++ backend_mock_data.patch | 6 +- src/backend.rs | 5 +- src/backend/endpoints.rs | 59 +++++++++----- src/backend/logic.rs | 165 ++++++++++++++++++++++++++++++++++----- src/backend/models.rs | 4 +- src/main.rs | 2 +- 9 files changed, 232 insertions(+), 53 deletions(-) create mode 100644 Makefile diff --git a/.gitignore b/.gitignore index 6e2fa0d..6beaab4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ **/*.rs.bk # Server state backups -apollo-state.cbor +**.cbor* +**/Cargo.lock assets/tailwind.css diff --git a/Cargo.toml b/Cargo.toml index 1786713..bd37c41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,16 +7,22 @@ edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -ciborium = "0.2.2" csv = "1.4.0" -dioxus = { version = "0.7.1", features = ["fullstack"] } -dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } -gloo-timers = { version = "0.3.0", features = ["futures"] } +dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false, optional = true } +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.2", features = ["fullstack"] } +# note: argon2 crate worth bearing in mind +rust-argon2 = { version = "3.0.0", optional = true } +secrecy = { version = "0.10.3", optional = true } tokio = { version = "1.48.0", optional = true } [features] default = ["web"] -web = ["dioxus/web"] +web = ["dioxus/web", "dep:gloo-timers", "dep:dioxus-primitives"] # desktop = ["dioxus/desktop"] # mobile = ["dioxus/mobile"] -server = ["dioxus/server", "dep:tokio"] +server = ["dioxus/server", "dep:tokio", "dep:secrecy"] +# save server state +server_state_save = ["server", "dep:ciborium", "dep:chacha20poly1305", "dep:rust-argon2"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c2ab645 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +APOLLO_STATE_PATH?=apollo-state.cbor.encrypted +APOLLO_EVENT_TITLE?=hack-a-polo +APOLLO_MESTER_JELSZO?=Password + +dx-args:=@server --server --features server_state_save @client --web + +serve: + APOLLO_STATE_PATH=${APOLLO_STATE_PATH} APOLLO_EVENT_TITLE=${APOLLO_EVENT_TITLE} APOLLO_MESTER_JELSZO=${APOLLO_MESTER_JELSZO} dx serve ${dx-args} + +build: + dx bundle ${dx-args} + +bundle: + dx bundle --release ${dx-args} + +clean: + cargo clean + +help list: + @echo *serve*: build, run and reload on changes + @echo build: build in debug mode + @echo bundle: build in release mode + @echo clean: clean target diff --git a/backend_mock_data.patch b/backend_mock_data.patch index 391ef7f..2db96a2 100644 --- a/backend_mock_data.patch +++ b/backend_mock_data.patch @@ -3,8 +3,8 @@ index 6b5f509..2bf923e 100644 --- a/src/backend/logic.rs +++ b/src/backend/logic.rs @@ -3,11 +3,53 @@ use dioxus::prelude::*; - use std::sync::{LazyLock, RwLock}; use std::{env, process}; + use tokio::sync::RwLock; -pub(super) static PUZZLES: LazyLock> = - LazyLock::new(|| RwLock::new(PuzzleSolutions::new())); @@ -57,6 +57,6 @@ index 6b5f509..2bf923e 100644 + ("genyo".to_string(), SolvedPuzzles::from(["".to_string()])), + ])) +}); - - /// without `name`, the app won't run + + /// without `key`, the app won't run fn ensure_env_var(key: &str) -> String { diff --git a/src/backend.rs b/src/backend.rs index 0fed89d..ef632c8 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -1,7 +1,10 @@ +#![deny(clippy::unwrap_used)] +#![forbid(unsafe_code)] + pub mod models; #[cfg(feature = "server")] -pub use logic::ensure_admin_env_vars; +pub use logic::prepare_startup; #[cfg(feature = "server")] mod logic; diff --git a/src/backend/endpoints.rs b/src/backend/endpoints.rs index 7181640..5b4d56a 100644 --- a/src/backend/endpoints.rs +++ b/src/backend/endpoints.rs @@ -1,8 +1,8 @@ -#[cfg(feature = "server")] -use super::logic::*; use super::models::*; use dioxus::fullstack::{CborEncoding, Streaming}; use dioxus::prelude::*; +#[cfg(feature = "server")] +use {super::logic::*, secrecy::ExposeSecret}; #[get("/api/event_title")] pub async fn event_title() -> Result { @@ -22,10 +22,15 @@ pub async fn state_stream() -> Result Result { - _ = backup_state().await; - let teams = &mut TEAMS.write().unwrap(); - (!teams.contains_key(&username)).or_forbidden("taken username")?; - _ = teams.insert(username, SolvedPuzzles::new()); + (!TEAMS.read().await.contains_key(&username)).or_forbidden("taken username")?; + + let mut teams_lock = TEAMS.write().await; + _ = teams_lock.insert(username, SolvedPuzzles::new()); + drop(teams_lock); + + #[cfg(feature = "server_state_save")] + state_save::save_state().await?; + Ok(String::from("helo, mehet!")) } @@ -35,17 +40,23 @@ pub async fn set_solution( puzzle_solutions: PuzzleSolutions, password: String, ) -> Result { - _ = backup_state().await; // submitting as admin - (*ADMIN_PASSWORD == password).or_unauthorized("incorrect password for APOLLO_MESTER")?; + (*ADMIN_PASSWORD.expose_secret() == password) + .or_unauthorized("incorrect password for APOLLO_MESTER")?; - let puzzles_state = &mut PUZZLES.write().unwrap(); + let puzzles_lock = PUZZLES.read().await; puzzle_solutions .keys() - .any(|new_k| !puzzles_state.contains_key(new_k)) + .any(|new_k| !puzzles_lock.contains_key(new_k)) .or_forbidden("one of the puzzles already set")?; + drop(puzzles_lock); - puzzles_state.extend(puzzle_solutions); + let mut puzzles_lock = PUZZLES.write().await; + puzzles_lock.extend(puzzle_solutions); + drop(puzzles_lock); + + #[cfg(feature = "server_state_save")] + state_save::save_state().await?; Ok(String::from("beallitottam a megoldast")) } @@ -57,22 +68,34 @@ pub async fn submit_solution( puzzle_id: PuzzleId, solution: PuzzleSolution, ) -> Result { - _ = backup_state().await; - let teams = &mut TEAMS.write().unwrap(); - let team_state = teams - .get_mut(&username) + TEAMS + .read() + .await + .contains_key(&username) .or_forbidden("no such team in the competition, join first")?; - let puzzles = &mut PUZZLES.read().unwrap(); if solution - == *puzzles + == *PUZZLES + .read() + .await .get(&puzzle_id) .or_not_found("no such puzzle")? .solution { - team_state + let mut teams_lock = TEAMS.write().await; + + let team_solved = teams_lock + .get_mut(&username) + .or_internal_server_error("shouldn't have got this far")?; + + team_solved .insert(puzzle_id) .or_forbidden("already solved this puzzle")?; + drop(teams_lock); + + #[cfg(feature = "server_state_save")] + state_save::save_state().await?; + Ok(String::from("oke, megoldottad, elmentettem!")) } else { HttpError::forbidden("incorrect solution")? diff --git a/src/backend/logic.rs b/src/backend/logic.rs index 6b5f509..94bcad8 100644 --- a/src/backend/logic.rs +++ b/src/backend/logic.rs @@ -1,7 +1,9 @@ use super::models::*; use dioxus::prelude::*; -use std::sync::{LazyLock, RwLock}; +use secrecy::SecretString; +use std::sync::LazyLock; use std::{env, process}; +use tokio::sync::RwLock; pub(super) static PUZZLES: LazyLock> = LazyLock::new(|| RwLock::new(PuzzleSolutions::new())); @@ -9,7 +11,7 @@ pub(super) static PUZZLES: LazyLock> = pub(super) static TEAMS: LazyLock> = LazyLock::new(|| RwLock::new(TeamsState::new())); -/// without `name`, the app won't run +/// without `key`, the app won't run fn ensure_env_var(key: &str) -> String { let Ok(value) = env::var(key) else { error!("{key:?} env var not set, can't proceed"); @@ -23,37 +25,158 @@ fn ensure_env_var(key: &str) -> String { } /// # exits with 1 -/// if necessary admin env vars aren't set -pub fn ensure_admin_env_vars() { +/// - if necessary admin env vars aren't set +/// - if should load state but can't +pub async fn prepare_startup() { _ = LazyLock::force(&ADMIN_PASSWORD); + #[cfg(feature = "server_state_save")] + if let Err(e) = state_save::load_state().await { + error!("couldn't load state: {e}, exiting..."); + process::exit(1); + } } -pub(super) static ADMIN_PASSWORD: LazyLock = - LazyLock::new(|| ensure_env_var("APOLLO_MESTER_JELSZO")); +pub(super) static ADMIN_PASSWORD: LazyLock = + LazyLock::new(|| ensure_env_var("APOLLO_MESTER_JELSZO").into()); pub(super) static EVENT_TITLE: LazyLock> = LazyLock::new(|| env::var("APOLLO_EVENT_TITLE")); pub(super) async fn get_game_state() -> (TeamsState, PuzzlesExisting) { let existing_puzzles = PUZZLES .read() - .unwrap() + .await .clone() .into_iter() - .map(|(id, sol)| (id, sol.value)) + .map(|(id, pzl)| (id, pzl.value)) .collect(); - (TEAMS.read().unwrap().clone(), existing_puzzles) + (TEAMS.read().await.clone(), existing_puzzles) } -/// just save a copy of the `PUZZLES` and `TEAMS` state to disk into a `cbor` file -/// TODO: add basic encryption using `ADMIN_PASSWORD` -pub(super) async fn backup_state() -> Result<()> { - let teams_state = TEAMS.read().unwrap().clone(); - let puzzles_state = PUZZLES.read().unwrap().clone(); - let mut buf = vec![]; - ciborium::into_writer(&(teams_state, puzzles_state), &mut buf) - .inspect_err(|e| error!("couldn't serialize into cbor: {e}"))?; - tokio::fs::write("apollo-state.cbor", buf) - .await - .inspect_err(|e| error!("couldn't write state to file: {e}"))?; - Ok(()) +#[cfg(feature = "server_state_save")] +pub(super) mod state_save { + use super::{ADMIN_PASSWORD, PUZZLES, TEAMS}; + use crate::backend::models::*; + use chacha20poly1305::aead::{Aead, Nonce, OsRng, rand_core::RngCore}; + use chacha20poly1305::{AeadCore, KeyInit, XChaCha20Poly1305}; + use dioxus::{fullstack::serde, prelude::*}; + use secrecy::{ExposeSecret, zeroize::Zeroize}; + use std::{path::Path, process, sync::LazyLock}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + type Res = Result>; + + static STATE_PATH: LazyLock = + LazyLock::new(|| super::ensure_env_var("APOLLO_STATE_PATH")); + + static ARGON2CONF: LazyLock = LazyLock::new(argon2::Config::default); + static SALT: LazyLock<[u8; 32]> = LazyLock::new(|| { + let mut salt = [0u8; 32]; + OsRng.fill_bytes(&mut salt); + salt + }); + // SECURITY: should it be generated on each save? + static NONCE: LazyLock> = + LazyLock::new(|| XChaCha20Poly1305::generate_nonce(&mut OsRng)); + static DERIVED_KEY: LazyLock> = LazyLock::new(|| { + let Ok(derived_key) = argon2::hash_raw( + ADMIN_PASSWORD.expose_secret().as_bytes(), + &*SALT, + &ARGON2CONF, + ) + .inspect_err(|e| error!("couldn't hash password: {e}")) else { + process::exit(1); + }; + derived_key + }); + + async fn encrypt(raw_content: &[u8]) -> Res> { + let cipher = XChaCha20Poly1305::new(DERIVED_KEY.as_slice().into()); + let encrypted_content = cipher + .encrypt(&NONCE, raw_content) + .map_err(|e| format!("encryption error: {e}"))?; + + let mut buf = Vec::with_capacity(SALT.len() + NONCE.len() + encrypted_content.len()); + + buf.write_all(&*SALT).await?; + buf.write_all(&NONCE).await?; + buf.write_all(&encrypted_content).await?; + + Ok(buf) + } + + async fn decrypt_state(encrypted_path: impl AsRef) -> Res> { + let mut salt = [0u8; 32]; + let mut nonce = Nonce::::default(); + + let mut encrypted_file = tokio::fs::File::open(encrypted_path).await?; + + let mut read_count = encrypted_file.read(&mut salt).await?; + if read_count != salt.len() { + return Err("couldn't read salt".into()); + } + + read_count = encrypted_file.read(&mut nonce).await?; + if read_count != nonce.len() { + return Err("couldn't read nonce".into()); + } + + let mut derived_key = argon2::hash_raw( + ADMIN_PASSWORD.expose_secret().as_bytes(), + &salt, + &ARGON2CONF, + )?; + + let cipher = XChaCha20Poly1305::new(derived_key.as_slice().into()); + let mut buf = vec![]; + let _n = encrypted_file.read_to_end(&mut buf).await?; + + let decrypted_content = cipher + .decrypt(&nonce, buf.as_slice()) + .map_err(|e| format!("error decrypting file, make sure you're trying to decrypt it with the same password that was used for it's encryption: {e}"))?; + + salt.zeroize(); + nonce.zeroize(); + derived_key.zeroize(); + + Ok(decrypted_content) + } + + /// save `PUZZLES` and `TEAMS` state to disk into an encrypted `cbor` file + pub async fn save_state() -> Result<(), HttpError> { + // internal server error + let ise = |msg: String| HttpError::new(StatusCode::INTERNAL_SERVER_ERROR, msg); + let teams_state = TEAMS.read().await.clone(); + let puzzles_state = PUZZLES.read().await.clone(); + + let mut state_buf = vec![]; + ciborium::into_writer(&(teams_state, puzzles_state), &mut state_buf) + .map_err(|e| ise(format!("couldn't serialize state to cbor: {e}")))?; + let encrypted_state = encrypt(&state_buf) + .await + .map_err(|e| ise(format!("couldn't encrypt state: {e}")))?; + tokio::fs::write(&*STATE_PATH, encrypted_state) + .await + .map_err(|e| ise(format!("couldn't write state to file: {e}")))?; + + Ok(()) + } + + async fn load_state_of serde::Deserialize<'de>, P: AsRef>(path: P) -> Res { + let encypted_data = decrypt_state(path).await?; + let state = ciborium::from_reader(encypted_data.as_slice())?; + Ok(state) + } + + /// load state from `STATE_PATH` into memory if it exists + pub(super) async fn load_state() -> Res<()> { + if !tokio::fs::try_exists(&*STATE_PATH).await? { + return Ok(()); // no need to load, it's fine + } + let (teams_state, puzzles_state): (TeamsState, PuzzleSolutions) = + load_state_of(&*STATE_PATH).await?; + PUZZLES.write().await.extend(puzzles_state); + TEAMS.write().await.extend(teams_state); + info!("successfully loaded saved state from {STATE_PATH:?} to memory"); + Ok(()) + } } diff --git a/src/backend/models.rs b/src/backend/models.rs index 9831fe7..811229a 100644 --- a/src/backend/models.rs +++ b/src/backend/models.rs @@ -1,5 +1,5 @@ use dioxus::fullstack::serde; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{HashMap, HashSet}; #[derive(Clone, PartialOrd, Ord, PartialEq, Eq, serde::Deserialize, serde::Serialize)] #[serde(crate = "dioxus::fullstack::serde")] @@ -15,5 +15,5 @@ pub type PuzzleSolution = String; pub type PuzzlesExisting = HashMap; pub type PuzzleSolutions = HashMap; /// solved puzzles of a team, or existing puzzles in general -pub type SolvedPuzzles = BTreeSet; +pub type SolvedPuzzles = HashSet; pub type TeamsState = HashMap; diff --git a/src/main.rs b/src/main.rs index 6be7a88..5f92c29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ fn main() { #[cfg(feature = "server")] dioxus::serve(|| async move { - backend::ensure_admin_env_vars(); + backend::prepare_startup().await; let router = dioxus::server::router(app::App); Ok(router) From cb7d5b36367b23f4020d906d06e43ac74fa36aab Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 9 Dec 2025 01:08:15 +0100 Subject: [PATCH 26/70] feat(client): popup red on error, blue on normal --- assets/main.css | 8 ++++++++ src/app.rs | 21 ++++++++++++++++----- src/app/actions.rs | 25 ++++++++++++++----------- src/app/models.rs | 5 +++++ 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/assets/main.css b/assets/main.css index 33f2a48..57eb185 100644 --- a/assets/main.css +++ b/assets/main.css @@ -14,6 +14,14 @@ height: 80vh; } +#msgerr.popup { + border: solid 2px darkred; +} + +#msgnorm.popup { + border: solid 2px steelblue; +} + .popup { width: auto; height: auto; diff --git a/src/app.rs b/src/app.rs index a19952b..a4174fc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,10 @@ mod models; mod utils; use crate::{ - app::{models::AuthState, utils::parse_puzzle_csv}, + app::{ + models::{AuthState, Message}, + utils::parse_puzzle_csv, + }, backend::models::{PuzzleSolutions, PuzzlesExisting, TeamsState}, components::score_table::ScoreTable, }; @@ -35,7 +38,7 @@ pub fn App() -> Element { let auth_current = auth.read(); let mut teams_state = use_signal(|| TeamsState::new()); let mut puzzles = use_signal(|| PuzzlesExisting::new()); - let mut message = use_signal(|| None::); + let mut message = use_signal(|| None::<(Message, String)>); let mut title = use_signal(|| None::); let mut is_fullscreen = use_signal(|| false); let mut parsed_puzzles = use_signal(|| PuzzleSolutions::new()); @@ -46,7 +49,7 @@ pub fn App() -> Element { title.set( crate::backend::endpoints::event_title() .await - .inspect_err(|e| message.set(Some(format!("Error: {}", e)))) + .inspect_err(|e| message.set(Some((Message::MsgErr, format!("Error: {}", e))))) .ok(), ); }); @@ -208,10 +211,18 @@ pub fn App() -> Element { } // Message popup - if let Some(msg) = &*message.read() { + if let Some(m) = &*message.read() { div { class: "popup", - "{msg}" + id: match m.0 { + Message::MsgNorm => { + "msgnorm" + }, + Message::MsgErr => { + "msgerr" + }, + }, + "{m.1}" } } } diff --git a/src/app/actions.rs b/src/app/actions.rs index 512e14e..9453867 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1,7 +1,7 @@ use dioxus::{prelude::*, signals::Signal}; use crate::{ - app::models::AuthState, + app::models::{AuthState, Message}, backend::models::{Puzzle, PuzzleSolutions}, }; @@ -16,7 +16,7 @@ pub async fn handle_join( username_current: String, password_current: String, auth: &mut Signal, - message: &mut Signal>, + message: &mut Signal>, ) { if let Ok(is_admin_user) = check_admin_username(username_current.clone()).await { if is_admin_user { @@ -25,7 +25,10 @@ pub async fn handle_join( // If password is empty, don't proceed yet if password_current.is_empty() { - message.set(Some("Adja meg az admin jelszót".to_string())); + message.set(Some(( + Message::MsgNorm, + "Adja meg az admin jelszót".to_string(), + ))); return; } auth.write().joined = true; @@ -35,13 +38,13 @@ pub async fn handle_join( match crate::backend::endpoints::join(username_current.clone()).await { Ok(msg) => { - message.set(Some(msg.clone())); + message.set(Some((Message::MsgNorm, msg.clone()))); auth.write().joined = true; auth.write().password = String::new(); auth.write().show_password_prompt = false; } Err(e) => { - message.set(Some(format!("Error: {}", e))); + message.set(Some((Message::MsgErr, format!("Error: {}", e)))); } } } @@ -50,7 +53,7 @@ pub async fn handle_user_submit( puzzle_id: &mut Signal, puzzle_solution: &mut Signal, username_current: String, - message: &mut Signal>, + message: &mut Signal>, ) { let puzzle_current = puzzle_id.read().clone(); let solution_current = puzzle_solution.read().clone(); @@ -62,12 +65,12 @@ pub async fn handle_user_submit( .await { Ok(msg) => { - message.set(Some(msg)); + message.set(Some((Message::MsgNorm, msg))); puzzle_id.set(String::new()); puzzle_solution.set(String::new()); } Err(e) => { - message.set(Some(format!("Error: {}", e))); + message.set(Some((Message::MsgErr, format!("Error: {}", e)))); } } } @@ -78,7 +81,7 @@ pub async fn handle_admin_submit( puzzle_solution: &mut Signal, parsed_puzzles: &Signal, password_current: String, - message: &mut Signal>, + message: &mut Signal>, ) { // Submit solution - call backend function directly let puzzle_current = puzzle_id.read().clone(); @@ -102,14 +105,14 @@ pub async fn handle_admin_submit( .await { Ok(msg) => { - message.set(Some(msg)); + message.set(Some((Message::MsgNorm, 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) => { - message.set(Some(format!("Error: {}", e))); + message.set(Some((Message::MsgErr, format!("Error: {}", e)))); } } } diff --git a/src/app/models.rs b/src/app/models.rs index 30ae7b8..7270bdf 100644 --- a/src/app/models.rs +++ b/src/app/models.rs @@ -5,3 +5,8 @@ pub struct AuthState { pub(crate) is_admin: bool, pub(crate) show_password_prompt: bool, } + +pub enum Message { + MsgNorm, + MsgErr, +} From 4b9e314c56cc33f48f10c2910a81154463e649fa Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 9 Dec 2025 01:30:28 +0100 Subject: [PATCH 27/70] fix(client): event title actually optional --- src/app.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index a4174fc..c68f337 100644 --- a/src/app.rs +++ b/src/app.rs @@ -50,7 +50,9 @@ pub fn App() -> Element { crate::backend::endpoints::event_title() .await .inspect_err(|e| message.set(Some((Message::MsgErr, format!("Error: {}", e))))) - .ok(), + .ok() + .unwrap_or("Apollo esemény".to_string()) + .into(), ); }); From e63870ddc7ec43c135bd249fe18b26b37674b346 Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 9 Dec 2025 02:17:52 +0100 Subject: [PATCH 28/70] fix(client): sort the table so it doesnt jump around --- src/app.rs | 19 +++++++++++++------ src/components/score_table.rs | 6 +++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index c68f337..e1370d8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use crate::{ models::{AuthState, Message}, utils::parse_puzzle_csv, }, - backend::models::{PuzzleSolutions, PuzzlesExisting, TeamsState}, + backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, components::score_table::ScoreTable, }; @@ -36,8 +36,8 @@ pub fn App() -> Element { show_password_prompt: false, }); let auth_current = auth.read(); - let mut teams_state = use_signal(|| TeamsState::new()); - let mut puzzles = use_signal(|| PuzzlesExisting::new()); + let mut teams_state = use_signal(|| Vec::<(PuzzleId, SolvedPuzzles)>::new()); + let mut puzzles = use_signal(|| Vec::<(PuzzleId, PuzzleValue)>::new()); let mut message = use_signal(|| None::<(Message, String)>); let mut title = use_signal(|| None::); let mut is_fullscreen = use_signal(|| false); @@ -63,10 +63,17 @@ pub fn App() -> Element { trace!("got stream"); // Then poll it for new events - while let Some(Ok(data)) = stream.next().await { + while let Some(Ok((new_team_state, new_puzzles))) = stream.next().await { trace!("got new data"); - teams_state.set(data.0); - puzzles.set(data.1); + let mut temp_p: Vec<(PuzzleId, PuzzleValue)> = new_puzzles.into_iter().collect(); + temp_p.sort(); + let mut temp_t: Vec<(PuzzleId, SolvedPuzzles)> = new_team_state.into_iter().collect(); + temp_t.sort_by(|a, b| { + b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)) // solved size, abc order if equal + }); + + puzzles.set(temp_p); + teams_state.set(temp_t); trace!("set new data"); } diff --git a/src/components/score_table.rs b/src/components/score_table.rs index a0025da..3b2a9c7 100644 --- a/src/components/score_table.rs +++ b/src/components/score_table.rs @@ -2,14 +2,14 @@ use dioxus::prelude::*; use dioxus_primitives::{ContentAlign, ContentSide}; use crate::{ - backend::models::{PuzzlesExisting, TeamsState}, + backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, components::tooltip::*, }; #[component] pub fn ScoreTable( - puzzles: Signal, - teams_state: Signal, + puzzles: Signal>, + teams_state: Signal>, toggle_fullscreen: EventHandler, ) -> Element { rsx! { From a6c9001aa29e4c92f664b553e804605a84f7c6a9 Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 9 Dec 2025 02:18:25 +0100 Subject: [PATCH 29/70] misc(client): minor qol tweaks --- src/app.rs | 8 +++++--- src/components/score_table.rs | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index e1370d8..a7e8d25 100644 --- a/src/app.rs +++ b/src/app.rs @@ -145,10 +145,12 @@ pub fn App() -> Element { div { class: if *is_fullscreen.read() { "table-only" } else { "normal" }, div { class: "others-container", - h1 { class: "mb-4 font-bold text-lg", - if let Some(t) = &*title.read() { + if let Some(t) = &*title.read() { + h1 { class: "mb-4 font-bold text-lg", "{t}", - } else { + } + } else { + h1 { class: "mb-4 font-bold text-md", "Betöltés..." } } diff --git a/src/components/score_table.rs b/src/components/score_table.rs index 3b2a9c7..fe00c36 100644 --- a/src/components/score_table.rs +++ b/src/components/score_table.rs @@ -17,7 +17,9 @@ pub fn ScoreTable( onclick: toggle_fullscreen, thead { tr { - th { class: "text-left pl-2", "." } + if !puzzles.read().is_empty() && !teams_state.read().is_empty() { + th { class: "text-left pl-2", "." } + } for (id, value) in puzzles.read().iter() { th { Tooltip { From 4cadc8033a79110de9e96148c58e6b0f4885e551 Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 9 Dec 2025 09:25:44 +0100 Subject: [PATCH 30/70] feat(client): big loading screen --- assets/main.css | 9 ++++ src/app.rs | 135 +++++++++++++++++++++++++----------------------- 2 files changed, 78 insertions(+), 66 deletions(-) diff --git a/assets/main.css b/assets/main.css index 57eb185..54a1a4d 100644 --- a/assets/main.css +++ b/assets/main.css @@ -14,6 +14,15 @@ height: 80vh; } +.loading { + position: fixed; + inset: 0; + display: grid; + place-items: center; + width: 100vw; + height: 100vh; +} + #msgerr.popup { border: solid 2px darkred; } diff --git a/src/app.rs b/src/app.rs index a7e8d25..a50a88b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -150,90 +150,93 @@ pub fn App() -> Element { "{t}", } } else { - h1 { class: "mb-4 font-bold text-md", - "Betöltés..." + div { class: "loading", + h1 { class: "font-bold text-[clamp(1rem,4vw,2.5rem)]", + "Várakozás az Apollo kiszolgálóra" + } } } - + if title.read().as_ref().is_some_and(|t| !t.is_empty()) { // Input section - div { class: "input-section", - if !auth_current.joined { - // Join form - input { class: INPUT, - r#type: "text", - placeholder: "Csapatnév", - value: "{auth_current.username}", - oninput: move |evt| auth.write().username = evt.value() - } - - if auth_current.show_password_prompt { - input { class: "ml-4 {INPUT}", - r#type: "password", - placeholder: "Admin jelszó", - value: "{auth_current.password}", - oninput: move |evt| auth.write().password = evt.value() + div { class: "input-section", + if !auth_current.joined { + // Join form + input { class: INPUT, + r#type: "text", + placeholder: "Csapatnév", + value: "{auth_current.username}", + oninput: move |evt| auth.write().username = evt.value() } - } - button { class: BUTTON, onclick: handle_action, "Belépés" } - } else { - // Submit form - input { class: INPUT, - r#type: "text", - placeholder: "Puzzle ID", - value: "{puzzle_id}", - oninput: move |evt| puzzle_id.set(evt.value()) - } - - input { class: "ml-4 {INPUT}", - r#type: "text", - placeholder: "Megoldás", - value: "{puzzle_solution}", - oninput: move |evt| puzzle_solution.set(evt.value()) - } + if auth_current.show_password_prompt { + input { class: "ml-4 {INPUT}", + r#type: "password", + placeholder: "Admin jelszó", + value: "{auth_current.password}", + oninput: move |evt| auth.write().password = evt.value() + } + } - if auth_current.is_admin { - input { class: "ml-4 {INPUT}", + button { class: BUTTON, onclick: handle_action, "Belépés" } + } else { + // Submit form + input { class: INPUT, r#type: "text", - placeholder: "Érték/Nyeremény", - value: "{puzzle_value}", - oninput: move |evt| puzzle_value.set(evt.value()) + placeholder: "Puzzle ID", + value: "{puzzle_id}", + oninput: move |evt| puzzle_id.set(evt.value()) } input { class: "ml-4 {INPUT}", - r#type: "password", - placeholder: "Admin jelszó", - value: "{auth_current.password}", - oninput: move |evt| auth.write().password = evt.value() + r#type: "text", + placeholder: "Megoldás", + value: "{puzzle_solution}", + oninput: move |evt| puzzle_solution.set(evt.value()) } - input { class: "ml-4 {CSV_INPUT}", - r#type: "file", - r#accept: ".csv", - onchange: handle_csv, + if auth_current.is_admin { + input { class: "ml-4 {INPUT}", + r#type: "text", + placeholder: "Érték/Nyeremény", + value: "{puzzle_value}", + oninput: move |evt| puzzle_value.set(evt.value()) + } + + input { class: "ml-4 {INPUT}", + r#type: "password", + placeholder: "Admin jelszó", + value: "{auth_current.password}", + oninput: move |evt| auth.write().password = evt.value() + } + + input { class: "ml-4 {CSV_INPUT}", + r#type: "file", + r#accept: ".csv", + onchange: handle_csv, + } + + button { class: BUTTON, onclick: handle_action, "Beállítás" } + } else { + button { class: BUTTON, onclick: handle_action, "Küldés" } } - button { class: BUTTON, onclick: handle_action, "Beállítás" } - } else { - button { class: BUTTON, onclick: handle_action, "Küldés" } } - } - } - // Message popup - if let Some(m) = &*message.read() { - div { - class: "popup", - id: match m.0 { - Message::MsgNorm => { - "msgnorm" + // Message popup + if let Some(m) = &*message.read() { + div { + class: "popup", + id: match m.0 { + Message::MsgNorm => { + "msgnorm" + }, + Message::MsgErr => { + "msgerr" + }, }, - Message::MsgErr => { - "msgerr" - }, - }, - "{m.1}" + "{m.1}" + } } } } From 7f717640665774009afcd9ecdf71a56ab45dd0d2 Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 9 Dec 2025 15:40:42 +0100 Subject: [PATCH 31/70] misc(client): better code for message handling --- src/app.rs | 4 ++-- src/app/actions.rs | 20 +++++++++++++------- src/app/utils.rs | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index a50a88b..93c9e90 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ mod utils; use crate::{ app::{ models::{AuthState, Message}, - utils::parse_puzzle_csv, + utils::{parse_puzzle_csv, popup_error}, }, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, components::score_table::ScoreTable, @@ -49,7 +49,7 @@ pub fn App() -> Element { title.set( crate::backend::endpoints::event_title() .await - .inspect_err(|e| message.set(Some((Message::MsgErr, format!("Error: {}", e))))) + .inspect_err(|e| popup_error(&mut message, format!("Hiba: {}", e))) .ok() .unwrap_or("Apollo esemény".to_string()) .into(), diff --git a/src/app/actions.rs b/src/app/actions.rs index 9453867..872c0a4 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1,7 +1,10 @@ use dioxus::{prelude::*, signals::Signal}; use crate::{ - app::models::{AuthState, Message}, + app::{ + models::{AuthState, Message}, + utils::{popup_error, popup_normal}, + }, backend::models::{Puzzle, PuzzleSolutions}, }; @@ -38,13 +41,13 @@ pub async fn handle_join( match crate::backend::endpoints::join(username_current.clone()).await { Ok(msg) => { - message.set(Some((Message::MsgNorm, msg.clone()))); + popup_normal(message, msg); auth.write().joined = true; auth.write().password = String::new(); auth.write().show_password_prompt = false; } Err(e) => { - message.set(Some((Message::MsgErr, format!("Error: {}", e)))); + popup_error(message, format!("Hiba: {}", e)); } } } @@ -65,12 +68,12 @@ pub async fn handle_user_submit( .await { Ok(msg) => { - message.set(Some((Message::MsgNorm, msg))); + popup_normal(message, msg); puzzle_id.set(String::new()); puzzle_solution.set(String::new()); } Err(e) => { - message.set(Some((Message::MsgErr, format!("Error: {}", e)))); + popup_error(message, format!("Hiba: {}", e)); } } } @@ -105,14 +108,17 @@ pub async fn handle_admin_submit( .await { Ok(msg) => { - message.set(Some((Message::MsgNorm, msg))); + popup_normal(message, 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) => { - message.set(Some((Message::MsgErr, format!("Error: {}", e)))); + popup_error( + message, + format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), + ); } } } diff --git a/src/app/utils.rs b/src/app/utils.rs index 3c813c3..4d8690a 100644 --- a/src/app/utils.rs +++ b/src/app/utils.rs @@ -27,3 +27,17 @@ pub fn parse_puzzle_csv(csv_text: &str) -> PuzzleSolutions { puzzles } + +pub fn popup_error( + signal_message: &mut Signal>, + text: impl std::fmt::Display, +) { + signal_message.set(Some((Message::MsgErr, text.to_string()))); +} + +pub fn popup_normal( + signal_message: &mut Signal>, + text: impl std::fmt::Display, +) { + signal_message.set(Some((Message::MsgNorm, text.to_string()))); +} From 911b97f80cff77d74a88e3a65e0bb1d466916eba Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 9 Dec 2025 15:43:36 +0100 Subject: [PATCH 32/70] fix(client): no unwrap, no unsafe, satisfy clippy --- src/app.rs | 42 ++++++++++++++++------------------- src/app/actions.rs | 33 ++++++++++++++-------------- src/app/models.rs | 1 + src/app/utils.rs | 55 ++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 85 insertions(+), 46 deletions(-) diff --git a/src/app.rs b/src/app.rs index 93c9e90..aa4d140 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,6 @@ +#![deny(clippy::unwrap_used)] +#![forbid(unsafe_code)] + use dioxus::prelude::*; mod actions; @@ -25,23 +28,17 @@ pub fn App() -> Element { trace!("kicking off app"); // State management variables trace!("initing variables"); - let mut puzzle_id = use_signal(|| String::new()); - let mut puzzle_solution = use_signal(|| String::new()); - let mut puzzle_value = use_signal(|| String::new()); - let mut auth = use_signal(|| AuthState { - username: String::new(), - password: String::new(), - joined: false, - is_admin: false, - show_password_prompt: false, - }); + let mut puzzle_id = use_signal(String::new); + let mut puzzle_solution = use_signal(String::new); + let mut puzzle_value = use_signal(String::new); + let mut auth = use_signal(AuthState::default); let auth_current = auth.read(); - let mut teams_state = use_signal(|| Vec::<(PuzzleId, SolvedPuzzles)>::new()); - let mut puzzles = use_signal(|| Vec::<(PuzzleId, PuzzleValue)>::new()); + let mut teams_state = use_signal(Vec::<(PuzzleId, SolvedPuzzles)>::new); + let mut puzzles = use_signal(Vec::<(PuzzleId, PuzzleValue)>::new); let mut message = use_signal(|| None::<(Message, String)>); let mut title = use_signal(|| None::); let mut is_fullscreen = use_signal(|| false); - let mut parsed_puzzles = use_signal(|| PuzzleSolutions::new()); + let mut parsed_puzzles = use_signal(PuzzleSolutions::new); trace!("variables inited"); // side effect handlers @@ -92,16 +89,15 @@ pub fn App() -> Element { // action handlers let handle_csv = move |evt: Event| async move { - let text = evt - .files() - .iter() - .next() - .unwrap() - .read_string() - .await - .unwrap(); - - parsed_puzzles.set(parse_puzzle_csv(&text)); + if let Some(file) = evt.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, &mut message)); + } else { + warn!("couldn't read selected file"); + }; }; let toggle_fullscreen = move |_| { diff --git a/src/app/actions.rs b/src/app/actions.rs index 872c0a4..bade807 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -21,22 +21,20 @@ pub async fn handle_join( auth: &mut Signal, message: &mut Signal>, ) { - if let Ok(is_admin_user) = check_admin_username(username_current.clone()).await { - if is_admin_user { - auth.write().is_admin = true; - auth.write().show_password_prompt = true; + if check_admin_username(username_current.clone()) + .await + .is_ok_and(|x| x) + { + auth.write().is_admin = true; + auth.write().show_password_prompt = true; - // If password is empty, don't proceed yet - if password_current.is_empty() { - message.set(Some(( - Message::MsgNorm, - "Adja meg az admin jelszót".to_string(), - ))); - return; - } - auth.write().joined = true; + // If password is empty, don't proceed yet + if password_current.is_empty() { + popup_normal(message, "Adja meg az admin jelszót"); return; } + auth.write().joined = true; + return; }; match crate::backend::endpoints::join(username_current.clone()).await { @@ -89,14 +87,17 @@ pub async fn handle_admin_submit( // Submit solution - call backend function directly let puzzle_current = puzzle_id.read().clone(); let solution_current = puzzle_solution.read().clone(); - let value_current = puzzle_value.read().clone(); + let Ok(value_current) = puzzle_value.read().parse() else { + popup_error(message, "Az érték csak szám lehet"); + return; + }; + match crate::backend::endpoints::set_solution( if parsed_puzzles.read().is_empty() { - let value_current_num = value_current.parse::().unwrap(); // TODO WARN unwrap PuzzleSolutions::from([( puzzle_current, Puzzle { - value: value_current_num, + value: value_current, solution: solution_current, }, )]) diff --git a/src/app/models.rs b/src/app/models.rs index 7270bdf..e593e4c 100644 --- a/src/app/models.rs +++ b/src/app/models.rs @@ -1,3 +1,4 @@ +#[derive(Default)] pub struct AuthState { pub(crate) username: String, pub(crate) password: String, diff --git a/src/app/utils.rs b/src/app/utils.rs index 4d8690a..c8f5bad 100644 --- a/src/app/utils.rs +++ b/src/app/utils.rs @@ -1,28 +1,69 @@ use csv::ReaderBuilder; use dioxus::prelude::*; -use crate::backend::models::{Puzzle, PuzzleSolutions}; +use crate::{ + app::models::Message, + backend::models::{Puzzle, PuzzleSolutions}, +}; -pub fn parse_puzzle_csv(csv_text: &str) -> PuzzleSolutions { +pub fn parse_puzzle_csv( + csv_text: &str, + message: &mut Signal>, +) -> 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); + warn!("skipping invalid CSV row: {}", e); + volte = true; continue; } }; - let id = record.get(0).unwrap().to_string(); - let solution = record.get(1).unwrap().to_string(); - let value: u32 = record.get(2).unwrap().parse().unwrap(); + 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, + }, + ); + } - puzzles.insert(id, Puzzle { solution, value }); + if volte { + popup_error( + message, + "néhány sort nem sikerült betölteni, nézd meg a konzolt", + ); } puzzles From 1bb42b0b04c0dd9e57fbc63049930d401437dabf Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 9 Dec 2025 15:52:23 +0100 Subject: [PATCH 33/70] misc(client): refactor score_table ui a bit --- src/components/score_table.rs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/components/score_table.rs b/src/components/score_table.rs index fe00c36..e8ea65a 100644 --- a/src/components/score_table.rs +++ b/src/components/score_table.rs @@ -22,16 +22,25 @@ pub fn ScoreTable( } for (id, value) in puzzles.read().iter() { th { - Tooltip { - TooltipTrigger { class: "text-(--light)", "Puzzle {id}" } - TooltipContent { - side: ContentSide::Top, - align: ContentAlign::Center, - div { class: "p-2 border border-(--dark2) rounded-md bg-(--dark)", - "value: {value}" - } - } + 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}" + // } + // } + // } } } } From 7f012c2bf87d75d9edc4b78a4a67212979633ffe Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 10 Dec 2025 00:21:47 +0100 Subject: [PATCH 34/70] feat(client): WIP select PuzzleId from dropdown --- src/app.rs | 39 ++++++-- src/components/mod.rs | 1 + src/components/select/component.rs | 116 +++++++++++++++++++++ src/components/select/mod.rs | 2 + src/components/select/style.css | 155 +++++++++++++++++++++++++++++ 5 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 src/components/select/component.rs create mode 100644 src/components/select/mod.rs create mode 100644 src/components/select/style.css diff --git a/src/app.rs b/src/app.rs index aa4d140..abed861 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ #![forbid(unsafe_code)] use dioxus::prelude::*; +// use dioxus_primitives::select::*; mod actions; mod models; @@ -13,7 +14,7 @@ use crate::{ utils::{parse_puzzle_csv, popup_error}, }, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, - components::score_table::ScoreTable, + components::{score_table::ScoreTable, select::*}, }; const FAVICON: Asset = asset!("/assets/favicon.ico"); @@ -134,6 +135,17 @@ pub fn App() -> Element { } }; + let teams_len = puzzles.read().len(); + let teamss = puzzles.clone().read().clone(); + let puzlez = teamss.iter().enumerate().map(|(i, (team_name, _))| { + rsx! { + SelectOption:: { index: i, value: team_name.clone(), text_value: "{team_name}", + {format!("{team_name}")} + SelectItemIndicator {} + } + } + }); + rsx! { document::Link { rel: "icon", href: FAVICON } document::Link { rel: "stylesheet", href: MAIN_CSS } @@ -176,11 +188,26 @@ pub fn App() -> Element { button { class: BUTTON, onclick: handle_action, "Belépés" } } else { // Submit form - input { class: INPUT, - r#type: "text", - placeholder: "Puzzle ID", - value: "{puzzle_id}", - oninput: move |evt| puzzle_id.set(evt.value()) + Select:: { + placeholder: "Feladat kiválasztása", + on_value_change: move |value: Option| { + if let Some(value) = value { + puzzle_id.set(value); + } + }, + SelectTrigger { aria_label: "Select Trigger", + class: "px-3 py-2 rounded-md border \ + bg-neutral-900 text-neutral-100 \ + border-neutral-700 \ + hover:border-neutral-500 \ + focus:outline-none focus:ring-2 focus:ring-blue-500/50", + width: "12rem", SelectValue {} } + SelectList { aria_label: "Select Demo", + SelectGroup { + SelectGroupLabel { "Feladatok" } + {puzlez} + } + } } input { class: "ml-4 {INPUT}", diff --git a/src/components/mod.rs b/src/components/mod.rs index 7896974..3a0f664 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,3 +1,4 @@ // AUTOGENERTED Components module pub mod score_table; pub mod tooltip; +pub mod select; diff --git a/src/components/select/component.rs b/src/components/select/component.rs new file mode 100644 index 0000000..88e70f0 --- /dev/null +++ b/src/components/select/component.rs @@ -0,0 +1,116 @@ +use dioxus::prelude::*; +use dioxus_primitives::select::{ + self, SelectGroupLabelProps, SelectGroupProps, SelectListProps, SelectOptionProps, SelectProps, + SelectTriggerProps, SelectValueProps, +}; + +#[component] +pub fn Select(props: SelectProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + select::Select { + class: "select", + value: props.value, + default_value: props.default_value, + on_value_change: props.on_value_change, + disabled: props.disabled, + name: props.name, + placeholder: props.placeholder, + roving_loop: props.roving_loop, + typeahead_timeout: props.typeahead_timeout, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn SelectTrigger(props: SelectTriggerProps) -> Element { + rsx! { + select::SelectTrigger { class: "select-trigger", attributes: props.attributes, + {props.children} + svg { + class: "select-expand-icon", + view_box: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + polyline { points: "6 9 12 15 18 9" } + } + } + } +} + +#[component] +pub fn SelectValue(props: SelectValueProps) -> Element { + rsx! { + select::SelectValue { attributes: props.attributes } + } +} + +#[component] +pub fn SelectList(props: SelectListProps) -> Element { + rsx! { + select::SelectList { + class: "select-list", + id: props.id, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn SelectGroup(props: SelectGroupProps) -> Element { + rsx! { + select::SelectGroup { + class: "select-group", + disabled: props.disabled, + id: props.id, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn SelectGroupLabel(props: SelectGroupLabelProps) -> Element { + rsx! { + select::SelectGroupLabel { + class: "select-group-label", + id: props.id, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn SelectOption(props: SelectOptionProps) -> Element { + rsx! { + select::SelectOption:: { + class: "select-option", + value: props.value, + text_value: props.text_value, + disabled: props.disabled, + id: props.id, + index: props.index, + aria_label: props.aria_label, + aria_roledescription: props.aria_roledescription, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn SelectItemIndicator() -> Element { + rsx! { + select::SelectItemIndicator { + svg { + class: "select-check-icon", + view_box: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + path { d: "M5 13l4 4L19 7" } + } + } + } +} diff --git a/src/components/select/mod.rs b/src/components/select/mod.rs new file mode 100644 index 0000000..9a8ae55 --- /dev/null +++ b/src/components/select/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/src/components/select/style.css b/src/components/select/style.css new file mode 100644 index 0000000..5a98dd3 --- /dev/null +++ b/src/components/select/style.css @@ -0,0 +1,155 @@ +.select { + position: relative; +} + +.select-trigger { + position: relative; + display: flex; + box-sizing: border-box; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 0.25rem; + padding: 8px 12px; + border: none; + border-radius: 0.5rem; + border-radius: calc(0.5rem); + background: none; + background: var(--light, var(--primary-color)) + var(--dark, var(--primary-color-3)); + box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) + var(--dark, var(--primary-color-7)); + color: var(--secondary-color-4); + cursor: pointer; + gap: 0.25rem; + transition: background-color 100ms ease-out; +} + +.select-trigger span[data-placeholder="true"] { + color: var(--secondary-color-5); +} + +.select[data-state="open"] .select-trigger { + pointer-events: none; +} + +.select-expand-icon { + width: 20px; + height: 20px; + fill: none; + stroke: var(--primary-color-7); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.select-check-icon { + width: 1rem; + height: 1rem; + fill: none; + stroke: var(--secondary-color-5); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.select[data-disabled="true"] .select-trigger { + color: var(--secondary-color-5); + cursor: not-allowed; +} + +.select-trigger:hover:not([data-disabled="true"]), +.select-trigger:focus-visible { + background: var(--light, var(--primary-color-4)) + var(--dark, var(--primary-color-5)); + color: var(--secondary-color-1); + outline: none; +} + +.select-list { + position: absolute; + z-index: 1000; + top: 100%; + left: 0; + min-width: 100%; + box-sizing: border-box; + padding: 0.25rem; + border-radius: 0.5rem; + margin-top: 0.25rem; + background: var(--light, var(--primary-color)) + var(--dark, var(--primary-color-5)); + box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) + var(--dark, var(--primary-color-7)); + opacity: 0; + pointer-events: none; + transform-origin: top; + will-change: transform, opacity; +} + +.select-list[data-state="closed"] { + animation: select-list-animate-out 150ms ease-in forwards; + pointer-events: none; +} + +@keyframes select-list-animate-out { + 0% { + opacity: 1; + transform: scale(1) translateY(0); + } + + 100% { + opacity: 0; + transform: scale(0.95) translateY(-2px); + } +} + +.select-list[data-state="open"] { + animation: select-list-animate-in 150ms ease-out forwards; + pointer-events: auto; +} + +@keyframes select-list-animate-in { + 0% { + opacity: 0; + transform: scale(0.95) translateY(-2px); + } + + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.select-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-radius: calc(0.5rem - 0.25rem); + cursor: pointer; + font-size: 14px; +} + +.select-option[data-disabled="true"] { + color: var(--secondary-color-5); + cursor: not-allowed; +} + +.select-option:hover:not([data-disabled="true"]), +.select-option:focus-visible { + background: var(--light, var(--primary-color-4)) + var(--dark, var(--primary-color-7)); + color: var(--secondary-color-1); + outline: none; +} + +.select-group-label { + padding: 4px 12px; + color: var(--secondary-color-5); + font-size: 0.75rem; +} + +[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; +} From 8056a1632d91ae5ab98aaaae2f26907c1d5ffdb3 Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 10 Dec 2025 01:43:26 +0100 Subject: [PATCH 35/70] feat(client): support latest api, auth state cookie, logs --- src/app.rs | 31 +++++++++++++++++-------------- src/app/actions.rs | 45 +++++++++++++++++---------------------------- 2 files changed, 34 insertions(+), 42 deletions(-) diff --git a/src/app.rs b/src/app.rs index abed861..6544dd3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ mod utils; use crate::{ app::{ models::{AuthState, Message}, - utils::{parse_puzzle_csv, popup_error}, + utils::{parse_puzzle_csv, popup_error, popup_normal}, }, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, components::{score_table::ScoreTable, select::*}, @@ -54,6 +54,14 @@ pub fn App() -> Element { ); }); + 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(&mut message, format!("Üdv újra, {name}")); + } + }); + use_future(move || async move { // Call the stream endpoint to get a stream of events trace!("calling state_stream"); @@ -109,33 +117,28 @@ pub fn App() -> Element { let handle_action = move |_| async move { trace!("action handler called"); - let username_current = auth.read().username.clone(); - let password_current = auth.read().password.clone(); - if !auth.read().joined { - actions::handle_join(username_current, password_current, &mut auth, &mut message).await; + actions::handle_join(&mut auth, &mut message).await; + if auth.read().joined { + teams_state + .write() + .push((auth.read().username.clone(), SolvedPuzzles::new())); + } } else if auth.read().is_admin { actions::handle_admin_submit( &mut puzzle_id, &mut puzzle_value, &mut puzzle_solution, &parsed_puzzles, - password_current, + auth.read().password.clone(), &mut message, ) .await; } else { - actions::handle_user_submit( - &mut puzzle_id, - &mut puzzle_solution, - username_current, - &mut message, - ) - .await; + actions::handle_user_submit(&mut puzzle_id, &mut puzzle_solution, &mut message).await; } }; - let teams_len = puzzles.read().len(); let teamss = puzzles.clone().read().clone(); let puzlez = teamss.iter().enumerate().map(|(i, (team_name, _))| { rsx! { diff --git a/src/app/actions.rs b/src/app/actions.rs index bade807..97171ef 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -16,20 +16,16 @@ async fn check_admin_username(username: String) -> Result { } pub async fn handle_join( - username_current: String, - password_current: String, auth: &mut Signal, message: &mut Signal>, ) { - if check_admin_username(username_current.clone()) - .await - .is_ok_and(|x| x) - { + let u = auth.read().username.clone(); + if check_admin_username(u.clone()).await.is_ok_and(|x| x) { auth.write().is_admin = true; auth.write().show_password_prompt = true; // If password is empty, don't proceed yet - if password_current.is_empty() { + if auth.read().password.is_empty() { popup_normal(message, "Adja meg az admin jelszót"); return; } @@ -37,15 +33,18 @@ pub async fn handle_join( return; }; - match crate::backend::endpoints::join(username_current.clone()).await { - Ok(msg) => { - popup_normal(message, msg); + match crate::backend::endpoints::join(u.clone()).await { + Ok(_) => { + popup_normal(message, format!("Üdv, {}", u)); auth.write().joined = true; auth.write().password = String::new(); auth.write().show_password_prompt = false; } Err(e) => { - popup_error(message, format!("Hiba: {}", e)); + popup_error( + message, + format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), + ); } } } @@ -53,18 +52,11 @@ pub async fn handle_join( pub async fn handle_user_submit( puzzle_id: &mut Signal, puzzle_solution: &mut Signal, - username_current: String, message: &mut Signal>, ) { let puzzle_current = puzzle_id.read().clone(); let solution_current = puzzle_solution.read().clone(); - match crate::backend::endpoints::submit_solution( - username_current, - puzzle_current, - solution_current, - ) - .await - { + match crate::backend::endpoints::submit_solution(puzzle_current, solution_current).await { Ok(msg) => { popup_normal(message, msg); puzzle_id.set(String::new()); @@ -85,20 +77,17 @@ pub async fn handle_admin_submit( message: &mut Signal>, ) { // Submit solution - call backend function directly - let puzzle_current = puzzle_id.read().clone(); - let solution_current = puzzle_solution.read().clone(); - let Ok(value_current) = puzzle_value.read().parse() else { - popup_error(message, "Az érték csak szám lehet"); - return; - }; - match crate::backend::endpoints::set_solution( if parsed_puzzles.read().is_empty() { + let Ok(value_current) = puzzle_value.read().parse() else { + popup_error(message, "Az érték csak szám lehet"); + return; + }; PuzzleSolutions::from([( - puzzle_current, + puzzle_id.read().clone(), Puzzle { value: value_current, - solution: solution_current, + solution: puzzle_solution.read().clone(), }, )]) } else { From e79c87f6e8222913a50da02ca654f9475f7e67f2 Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 10 Dec 2025 01:44:20 +0100 Subject: [PATCH 36/70] fix(client): score_table De Morgan ;D --- src/components/score_table.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/score_table.rs b/src/components/score_table.rs index e8ea65a..93ae3c8 100644 --- a/src/components/score_table.rs +++ b/src/components/score_table.rs @@ -17,7 +17,7 @@ pub fn ScoreTable( onclick: toggle_fullscreen, thead { tr { - if !puzzles.read().is_empty() && !teams_state.read().is_empty() { + if !puzzles.read().is_empty() || !teams_state.read().is_empty() { th { class: "text-left pl-2", "." } } for (id, value) in puzzles.read().iter() { From c53c819c0a87099d0bd77e2d98269c13b1e2710a Mon Sep 17 00:00:00 2001 From: csboo Date: Wed, 10 Dec 2025 08:29:43 +0100 Subject: [PATCH 37/70] feat(client): team status indicator, dynamic dropdown --- src/app.rs | 59 ++++++++++++++++++++++++++++------- src/app/actions.rs | 1 + src/components/mod.rs | 3 +- src/components/score_table.rs | 6 ++-- src/components/team_status.rs | 14 +++++++++ 5 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 src/components/team_status.rs diff --git a/src/app.rs b/src/app.rs index 6544dd3..6f8e9d8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use crate::{ utils::{parse_puzzle_csv, popup_error, popup_normal}, }, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, - components::{score_table::ScoreTable, select::*}, + components::{score_table::ScoreTable, select::*, team_status::TeamStatus}, }; const FAVICON: Asset = asset!("/assets/favicon.ico"); @@ -34,7 +34,7 @@ pub fn App() -> Element { let mut puzzle_value = use_signal(String::new); let mut auth = use_signal(AuthState::default); let auth_current = auth.read(); - let mut teams_state = use_signal(Vec::<(PuzzleId, SolvedPuzzles)>::new); + let mut teams_state = use_signal(Vec::<(String, SolvedPuzzles)>::new); let mut puzzles = use_signal(Vec::<(PuzzleId, PuzzleValue)>::new); let mut message = use_signal(|| None::<(Message, String)>); let mut title = use_signal(|| None::); @@ -104,6 +104,7 @@ pub fn App() -> Element { return; }; parsed_puzzles.set(parse_puzzle_csv(&text, &mut message)); + debug!("set puzzles from csv"); } else { warn!("couldn't read selected file"); }; @@ -139,16 +140,43 @@ pub fn App() -> Element { } }; - let teamss = puzzles.clone().read().clone(); - let puzlez = teamss.iter().enumerate().map(|(i, (team_name, _))| { - rsx! { - SelectOption:: { index: i, value: team_name.clone(), text_value: "{team_name}", - {format!("{team_name}")} - SelectItemIndicator {} - } - } + let teams = teams_state.read(); + let ref_puzzles = puzzles.read(); + + let solved = teams + .iter() + .find(|(team, _)| team == &auth_current.username) + .map(|(_, solved)| solved); + + let puzzle_dropdown_options = solved.into_iter().flat_map(|solved| { + ref_puzzles + .iter() + .filter(|(id, _)| !solved.contains(id)) + .enumerate() + .map(|(i, (id, _))| { + rsx! { + SelectOption:: { + index: i, + value: id.clone(), + text_value: "{id}", + {format!("{id}")} + SelectItemIndicator {} + } + } + }) }); + let points: u32 = solved + .as_ref() + .map(|solved| { + ref_puzzles + .iter() + .filter(|(id, _)| solved.contains(id)) + .map(|(_, value)| *value) + .sum() + }) + .unwrap_or(0); + rsx! { document::Link { rel: "icon", href: FAVICON } document::Link { rel: "stylesheet", href: MAIN_CSS } @@ -208,7 +236,7 @@ pub fn App() -> Element { SelectList { aria_label: "Select Demo", SelectGroup { SelectGroupLabel { "Feladatok" } - {puzlez} + {puzzle_dropdown_options} } } } @@ -249,6 +277,13 @@ pub fn App() -> Element { } } + div { class: "mt-5", + TeamStatus { + team: auth_current.username.clone(), + points: points, + } + } + // Message popup if let Some(m) = &*message.read() { div { @@ -268,7 +303,7 @@ pub fn App() -> Element { } // Teams and puzzles table - div { class: "table-container", + div { class: "table-container mt-5", ScoreTable { puzzles: puzzles, teams_state: teams_state, diff --git a/src/app/actions.rs b/src/app/actions.rs index 97171ef..a587f46 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -79,6 +79,7 @@ pub async fn handle_admin_submit( // Submit solution - call backend function directly match crate::backend::endpoints::set_solution( if parsed_puzzles.read().is_empty() { + debug!("parsed puzzles is empty, trying from manual values"); let Ok(value_current) = puzzle_value.read().parse() else { popup_error(message, "Az érték csak szám lehet"); return; diff --git a/src/components/mod.rs b/src/components/mod.rs index 3a0f664..d2277b2 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,4 +1,5 @@ // AUTOGENERTED Components module pub mod score_table; -pub mod tooltip; pub mod select; +pub mod team_status; +pub mod tooltip; diff --git a/src/components/score_table.rs b/src/components/score_table.rs index 93ae3c8..1ec190e 100644 --- a/src/components/score_table.rs +++ b/src/components/score_table.rs @@ -1,9 +1,9 @@ use dioxus::prelude::*; -use dioxus_primitives::{ContentAlign, ContentSide}; +// use dioxus_primitives::{ContentAlign, ContentSide}; use crate::{ backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, - components::tooltip::*, + // components::tooltip::*, }; #[component] @@ -13,7 +13,7 @@ pub fn ScoreTable( toggle_fullscreen: EventHandler, ) -> Element { rsx! { - table { class: "mt-5", + table { class: "", onclick: toggle_fullscreen, thead { tr { 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}" + } + ) +} From d89b6ae37d34cb4d00d6b8adf3d3f3887c25deba Mon Sep 17 00:00:00 2001 From: csboo Date: Thu, 11 Dec 2025 11:20:29 +0100 Subject: [PATCH 38/70] feat(client): ui update, better code --- src/app.rs | 153 ++++++++++++++++---------------- src/app/models.rs | 1 + src/components/message_popup.rs | 21 +++++ src/components/mod.rs | 2 + src/components/select/style.css | 5 +- 5 files changed, 104 insertions(+), 78 deletions(-) create mode 100644 src/components/message_popup.rs diff --git a/src/app.rs b/src/app.rs index 6f8e9d8..6889b24 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,13 +8,17 @@ mod actions; mod models; mod utils; +pub use crate::app::models::Message; + use crate::{ app::{ - models::{AuthState, Message}, + models::AuthState, utils::{parse_puzzle_csv, popup_error, popup_normal}, }, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, - components::{score_table::ScoreTable, select::*, team_status::TeamStatus}, + components::{ + message_popup::MessagePopup, score_table::ScoreTable, select::*, team_status::TeamStatus, + }, }; const FAVICON: Asset = asset!("/assets/favicon.ico"); @@ -158,8 +162,8 @@ pub fn App() -> Element { SelectOption:: { index: i, value: id.clone(), - text_value: "{id}", - {format!("{id}")} + text_value: "{id}. feladat", + {format!("{id}. feladat")} SelectItemIndicator {} } } @@ -195,8 +199,20 @@ pub fn App() -> Element { } } } + + } // div: other-container + + div { class: "table-container mt-5", + ScoreTable { + puzzles: puzzles, + teams_state: teams_state, + toggle_fullscreen: toggle_fullscreen, + } + } // div: table-container + + div { class: "others-container mt-5", if title.read().as_ref().is_some_and(|t| !t.is_empty()) { - // Input section + // Input section div { class: "input-section", if !auth_current.joined { // Join form @@ -219,97 +235,82 @@ pub fn App() -> Element { button { class: BUTTON, onclick: handle_action, "Belépés" } } else { // Submit form - Select:: { - placeholder: "Feladat kiválasztása", - on_value_change: move |value: Option| { - if let Some(value) = value { - puzzle_id.set(value); + div { class: "input-flexy-boxy flex flex-row h-[50px]", + Select:: { + placeholder: "Feladat kiválasztása", + on_value_change: move |value: Option| { + if let Some(value) = value { + puzzle_id.set(value); + } + }, + SelectTrigger { + aria_label: "Select Trigger", + width: "12rem", + SelectValue {} } - }, - SelectTrigger { aria_label: "Select Trigger", - class: "px-3 py-2 rounded-md border \ - bg-neutral-900 text-neutral-100 \ - border-neutral-700 \ - hover:border-neutral-500 \ - focus:outline-none focus:ring-2 focus:ring-blue-500/50", - width: "12rem", SelectValue {} } - SelectList { aria_label: "Select Demo", - SelectGroup { - SelectGroupLabel { "Feladatok" } - {puzzle_dropdown_options} + SelectList { + aria_label: "Select Demo", + SelectGroup { + {puzzle_dropdown_options} + } } } - } - - input { class: "ml-4 {INPUT}", - r#type: "text", - placeholder: "Megoldás", - value: "{puzzle_solution}", - oninput: move |evt| puzzle_solution.set(evt.value()) - } - if auth_current.is_admin { input { class: "ml-4 {INPUT}", r#type: "text", - placeholder: "Érték/Nyeremény", - value: "{puzzle_value}", - oninput: move |evt| puzzle_value.set(evt.value()) + placeholder: "Megoldás", + value: "{puzzle_solution}", + oninput: move |evt| puzzle_solution.set(evt.value()) } - input { class: "ml-4 {INPUT}", - r#type: "password", - placeholder: "Admin jelszó", - value: "{auth_current.password}", - oninput: move |evt| auth.write().password = evt.value() - } + if auth_current.is_admin { + input { class: "ml-4 {INPUT}", + r#type: "text", + placeholder: "Érték/Nyeremény", + value: "{puzzle_value}", + oninput: move |evt| puzzle_value.set(evt.value()) + } - input { class: "ml-4 {CSV_INPUT}", - r#type: "file", - r#accept: ".csv", - onchange: handle_csv, - } + input { class: "ml-4 {INPUT}", + r#type: "password", + placeholder: "Admin jelszó", + value: "{auth_current.password}", + oninput: move |evt| auth.write().password = evt.value() + } + + input { class: "ml-4 {CSV_INPUT}", + r#type: "file", + r#accept: ".csv", + onchange: handle_csv, + } - button { class: BUTTON, onclick: handle_action, "Beállítás" } - } else { - button { class: BUTTON, onclick: handle_action, "Küldés" } + button { class: BUTTON, onclick: handle_action, "Beállítás" } + } else { + button { class: BUTTON, onclick: handle_action, "Küldés" } + } } } - } + } // div: input-section - div { class: "mt-5", - TeamStatus { - team: auth_current.username.clone(), - points: points, + if auth_current.joined { + div { class: "mt-5", + TeamStatus { + team: auth_current.username.clone(), + points: points, + } } } // Message popup if let Some(m) = &*message.read() { - div { - class: "popup", - id: match m.0 { - Message::MsgNorm => { - "msgnorm" - }, - Message::MsgErr => { - "msgerr" - }, - }, - "{m.1}" + MessagePopup { + level: m.0.clone(), + text: m.1.clone(), } - } + } // end message } - } - // Teams and puzzles table - - div { class: "table-container mt-5", - ScoreTable { - puzzles: puzzles, - teams_state: teams_state, - toggle_fullscreen: toggle_fullscreen, - } - } - } + } // div: other-container + } // end main div } } diff --git a/src/app/models.rs b/src/app/models.rs index e593e4c..7a33660 100644 --- a/src/app/models.rs +++ b/src/app/models.rs @@ -7,6 +7,7 @@ pub struct AuthState { pub(crate) show_password_prompt: bool, } +#[derive(Clone, PartialEq)] pub enum Message { MsgNorm, MsgErr, diff --git a/src/components/message_popup.rs b/src/components/message_popup.rs new file mode 100644 index 0000000..32a7958 --- /dev/null +++ b/src/components/message_popup.rs @@ -0,0 +1,21 @@ +use dioxus::prelude::*; + +use crate::app::Message; + +#[component] +pub fn MessagePopup(text: String, level: Message) -> Element { + rsx! { + div { + class: "popup", + id: match level { + Message::MsgNorm => { + "msgnorm" + }, + Message::MsgErr => { + "msgerr" + }, + }, + "{text}" + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index d2277b2..20e6ee0 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,4 +1,6 @@ // AUTOGENERTED Components module +pub mod input_section; +pub mod message_popup; pub mod score_table; pub mod select; pub mod team_status; diff --git a/src/components/select/style.css b/src/components/select/style.css index 5a98dd3..7fe5d87 100644 --- a/src/components/select/style.css +++ b/src/components/select/style.css @@ -15,8 +15,9 @@ border-radius: 0.5rem; border-radius: calc(0.5rem); background: none; - background: var(--light, var(--primary-color)) - var(--dark, var(--primary-color-3)); + /* background: var(--light, var(--primary-color)) */ + /* var(--dark, var(--primary-color-3)); */ + background: white; box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7)); color: var(--secondary-color-4); From bad0ab485c3d628efca1a10c721bd1b5e8b5cd37 Mon Sep 17 00:00:00 2001 From: csboo Date: Fri, 12 Dec 2025 12:15:55 +0100 Subject: [PATCH 39/70] refactor(client): sparate everything into crates at last --- src/app.rs | 242 ++++---------------------------- src/app/actions.rs | 107 +++++++++++--- src/app/hooks.rs | 61 ++++++++ src/app/models.rs | 2 +- src/app/utils.rs | 8 +- src/components/input_section.rs | 129 +++++++++++++++++ 6 files changed, 311 insertions(+), 238 deletions(-) create mode 100644 src/app/hooks.rs create mode 100644 src/components/input_section.rs diff --git a/src/app.rs b/src/app.rs index 6889b24..8c9a088 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,148 +1,52 @@ #![deny(clippy::unwrap_used)] #![forbid(unsafe_code)] -use dioxus::prelude::*; +use dioxus::{fullstack::Lazy, prelude::*}; // use dioxus_primitives::select::*; -mod actions; +pub mod actions; +mod hooks; mod models; mod utils; -pub use crate::app::models::Message; +pub use crate::app::models::{AuthState, Message}; use crate::{ - app::{ - models::AuthState, - utils::{parse_puzzle_csv, popup_error, popup_normal}, - }, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, components::{ - message_popup::MessagePopup, score_table::ScoreTable, select::*, team_status::TeamStatus, + input_section::InputSection, message_popup::MessagePopup, score_table::ScoreTable, + team_status::TeamStatus, }, }; const FAVICON: Asset = asset!("/assets/favicon.ico"); const MAIN_CSS: Asset = asset!("/assets/main.css"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); -const BUTTON: &str = "ml-4 w-30 px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; -const INPUT: &str = "w-50 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"; -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"; #[component] pub fn App() -> Element { trace!("kicking off app"); // State management variables trace!("initing variables"); - let mut puzzle_id = use_signal(String::new); - let mut puzzle_solution = use_signal(String::new); - let mut puzzle_value = use_signal(String::new); - let mut auth = use_signal(AuthState::default); + 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 mut teams_state = use_signal(Vec::<(String, SolvedPuzzles)>::new); - let mut puzzles = use_signal(Vec::<(PuzzleId, PuzzleValue)>::new); - let mut message = use_signal(|| None::<(Message, String)>); - let mut title = use_signal(|| None::); - let mut is_fullscreen = use_signal(|| false); - let mut parsed_puzzles = use_signal(PuzzleSolutions::new); + let teams_state = use_signal(Vec::<(String, SolvedPuzzles)>::new); + let puzzles = use_signal(Vec::<(PuzzleId, PuzzleValue)>::new); + let message = use_signal(|| None::<(Message, String)>); + let title = use_signal(|| None::); + let is_fullscreen = use_signal(|| false); + let parsed_puzzles = use_signal(PuzzleSolutions::new); trace!("variables inited"); // side effect handlers - use_future(move || async move { - title.set( - crate::backend::endpoints::event_title() - .await - .inspect_err(|e| popup_error(&mut message, format!("Hiba: {}", e))) - .ok() - .unwrap_or("Apollo esemény".to_string()) - .into(), - ); - }); - - 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(&mut message, format!("Üdv újra, {name}")); - } - }); - - use_future(move || async move { - // Call the stream endpoint to get a stream of events - trace!("calling state_stream"); - let mut stream = crate::backend::endpoints::state_stream().await?; - trace!("got stream"); - - // Then poll it for new events - while let Some(Ok((new_team_state, new_puzzles))) = stream.next().await { - trace!("got new data"); - let mut temp_p: Vec<(PuzzleId, PuzzleValue)> = new_puzzles.into_iter().collect(); - temp_p.sort(); - let mut temp_t: Vec<(PuzzleId, SolvedPuzzles)> = new_team_state.into_iter().collect(); - temp_t.sort_by(|a, b| { - b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)) // solved size, abc order if equal - }); - - puzzles.set(temp_p); - teams_state.set(temp_t); - trace!("set new data"); - } - - dioxus::Ok(()) - }); - use_effect(move || { - if message.read().is_some() { - // hide after 5 seconds - spawn(async move { - gloo_timers::future::sleep(std::time::Duration::from_secs(5)).await; - message.set(None); - }); - } - }); - - // action handlers - let handle_csv = move |evt: Event| async move { - if let Some(file) = evt.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, &mut message)); - debug!("set puzzles from csv"); - } else { - warn!("couldn't read selected file"); - }; - }; - - let toggle_fullscreen = move |_| { - trace!("fullscreen toggle called"); - let fullscreen_current = *is_fullscreen.read(); - is_fullscreen.set(!fullscreen_current); - }; - - let handle_action = move |_| async move { - trace!("action handler called"); - if !auth.read().joined { - actions::handle_join(&mut auth, &mut message).await; - if auth.read().joined { - teams_state - .write() - .push((auth.read().username.clone(), SolvedPuzzles::new())); - } - } else if auth.read().is_admin { - actions::handle_admin_submit( - &mut puzzle_id, - &mut puzzle_value, - &mut puzzle_solution, - &parsed_puzzles, - auth.read().password.clone(), - &mut message, - ) - .await; - } else { - actions::handle_user_submit(&mut puzzle_id, &mut puzzle_solution, &mut message).await; - } - }; + hooks::auto_hide_message(message); + hooks::check_auth(auth, message); + hooks::load_title(title, message); + hooks::subscribe_stream(teams_state, puzzles); let teams = teams_state.read(); let ref_puzzles = puzzles.read(); @@ -152,24 +56,6 @@ pub fn App() -> Element { .find(|(team, _)| team == &auth_current.username) .map(|(_, solved)| solved); - let puzzle_dropdown_options = solved.into_iter().flat_map(|solved| { - ref_puzzles - .iter() - .filter(|(id, _)| !solved.contains(id)) - .enumerate() - .map(|(i, (id, _))| { - rsx! { - SelectOption:: { - index: i, - value: id.clone(), - text_value: "{id}. feladat", - {format!("{id}. feladat")} - SelectItemIndicator {} - } - } - }) - }); - let points: u32 = solved .as_ref() .map(|solved| { @@ -206,7 +92,7 @@ pub fn App() -> Element { ScoreTable { puzzles: puzzles, teams_state: teams_state, - toggle_fullscreen: toggle_fullscreen, + toggle_fullscreen: actions::toggle_fullscreen(is_fullscreen.clone()), } } // div: table-container @@ -214,82 +100,16 @@ pub fn App() -> Element { if title.read().as_ref().is_some_and(|t| !t.is_empty()) { // Input section div { class: "input-section", - if !auth_current.joined { - // Join form - input { class: INPUT, - r#type: "text", - placeholder: "Csapatnév", - value: "{auth_current.username}", - oninput: move |evt| auth.write().username = evt.value() - } - - if auth_current.show_password_prompt { - input { class: "ml-4 {INPUT}", - r#type: "password", - placeholder: "Admin jelszó", - value: "{auth_current.password}", - oninput: move |evt| auth.write().password = evt.value() - } - } - - button { class: BUTTON, onclick: handle_action, "Belépés" } - } else { - // Submit form - div { class: "input-flexy-boxy flex flex-row h-[50px]", - Select:: { - placeholder: "Feladat kiválasztása", - on_value_change: move |value: Option| { - if let Some(value) = value { - puzzle_id.set(value); - } - }, - SelectTrigger { - aria_label: "Select Trigger", - width: "12rem", - SelectValue {} - } - SelectList { - aria_label: "Select Demo", - SelectGroup { - {puzzle_dropdown_options} - } - } - } - - input { class: "ml-4 {INPUT}", - r#type: "text", - placeholder: "Megoldás", - value: "{puzzle_solution}", - oninput: move |evt| puzzle_solution.set(evt.value()) - } - - if auth_current.is_admin { - input { class: "ml-4 {INPUT}", - r#type: "text", - placeholder: "Érték/Nyeremény", - value: "{puzzle_value}", - oninput: move |evt| puzzle_value.set(evt.value()) - } - - input { class: "ml-4 {INPUT}", - r#type: "password", - placeholder: "Admin jelszó", - value: "{auth_current.password}", - oninput: move |evt| auth.write().password = evt.value() - } - - input { class: "ml-4 {CSV_INPUT}", - r#type: "file", - r#accept: ".csv", - onchange: handle_csv, - } - - button { class: BUTTON, onclick: handle_action, "Beállítás" } - } else { - button { class: BUTTON, onclick: handle_action, "Küldés" } - } - } - + InputSection { + auth_current: auth_current.clone(), + auth, + message, + puzzle_id, + puzzle_value, + puzzle_solution, + parsed_puzzles, + teams_state, + puzzles, } } // div: input-section diff --git a/src/app/actions.rs b/src/app/actions.rs index a587f46..83f8f1d 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -3,9 +3,9 @@ use dioxus::{prelude::*, signals::Signal}; use crate::{ app::{ models::{AuthState, Message}, - utils::{popup_error, popup_normal}, + utils::{parse_puzzle_csv, popup_error, popup_normal}, }, - backend::models::{Puzzle, PuzzleSolutions}, + backend::models::{Puzzle, PuzzleSolutions, SolvedPuzzles}, }; // TODO could be handeled in much better ways @@ -15,10 +15,7 @@ async fn check_admin_username(username: String) -> Result { Ok(username == admin_username) } -pub async fn handle_join( - auth: &mut Signal, - message: &mut Signal>, -) { +pub async fn handle_join(mut auth: Signal, message: Signal>) { let u = auth.read().username.clone(); if check_admin_username(u.clone()).await.is_ok_and(|x| x) { auth.write().is_admin = true; @@ -26,7 +23,7 @@ pub async fn handle_join( // If password is empty, don't proceed yet if auth.read().password.is_empty() { - popup_normal(message, "Adja meg az admin jelszót"); + popup_normal(message.clone(), "Adja meg az admin jelszót"); return; } auth.write().joined = true; @@ -35,14 +32,14 @@ pub async fn handle_join( match crate::backend::endpoints::join(u.clone()).await { Ok(_) => { - popup_normal(message, format!("Üdv, {}", u)); + popup_normal(message.clone(), format!("Üdv, {}", u)); auth.write().joined = true; auth.write().password = String::new(); auth.write().show_password_prompt = false; } Err(e) => { popup_error( - message, + message.clone(), format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), ); } @@ -50,38 +47,38 @@ pub async fn handle_join( } pub async fn handle_user_submit( - puzzle_id: &mut Signal, - puzzle_solution: &mut Signal, - message: &mut Signal>, + mut puzzle_id: Signal, + mut puzzle_solution: Signal, + message: Signal>, ) { let puzzle_current = puzzle_id.read().clone(); let solution_current = puzzle_solution.read().clone(); match crate::backend::endpoints::submit_solution(puzzle_current, solution_current).await { Ok(msg) => { - popup_normal(message, msg); + popup_normal(message.clone(), msg); puzzle_id.set(String::new()); puzzle_solution.set(String::new()); } Err(e) => { - popup_error(message, format!("Hiba: {}", e)); + popup_error(message.clone(), format!("Hiba: {}", e)); } } } pub async fn handle_admin_submit( - puzzle_id: &mut Signal, - puzzle_value: &mut Signal, - puzzle_solution: &mut Signal, - parsed_puzzles: &Signal, + mut puzzle_id: Signal, + mut puzzle_value: Signal, + mut puzzle_solution: Signal, + parsed_puzzles: Signal, password_current: String, - message: &mut Signal>, + message: Signal>, ) { // Submit solution - call backend function directly match crate::backend::endpoints::set_solution( if parsed_puzzles.read().is_empty() { debug!("parsed puzzles is empty, trying from manual values"); let Ok(value_current) = puzzle_value.read().parse() else { - popup_error(message, "Az érték csak szám lehet"); + popup_error(message.clone(), "Az érték csak szám lehet"); return; }; PuzzleSolutions::from([( @@ -99,7 +96,7 @@ pub async fn handle_admin_submit( .await { Ok(msg) => { - popup_normal(message, msg); + popup_normal(message.clone(), msg); puzzle_id.set(String::new()); puzzle_solution.set(String::new()); puzzle_value.set(String::new()); @@ -107,9 +104,75 @@ pub async fn handle_admin_submit( } Err(e) => { popup_error( - message, + message.clone(), format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), ); } } } + +pub fn handle_csv( + mut parsed_puzzles: Signal, + message: Signal>, +) -> 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, message.clone())); + 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, + message: Signal>, + puzzle_id: Signal, + puzzle_value: Signal, + puzzle_solution: Signal, + parsed_puzzles: Signal, + mut teams_state: Signal>, +) -> impl FnMut(Event) + 'static { + move |_| { + spawn(async move { + trace!("action handler called"); + if !auth.read().joined { + self::handle_join(auth, message).await; + if auth.read().joined { + teams_state + .write() + .push((auth.read().username.clone(), SolvedPuzzles::new())); + } + } else if auth.read().is_admin { + self::handle_admin_submit( + puzzle_id, + puzzle_value, + puzzle_solution, + parsed_puzzles, + auth.read().password.clone(), + message, + ) + .await; + } else { + self::handle_user_submit(puzzle_id, puzzle_solution, message).await; + } + }); + } +} diff --git a/src/app/hooks.rs b/src/app/hooks.rs new file mode 100644 index 0000000..3d1981d --- /dev/null +++ b/src/app/hooks.rs @@ -0,0 +1,61 @@ +use dioxus::prelude::*; + +use crate::{ + app::{ + AuthState, Message, + utils::{popup_error, popup_normal}, + }, + backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, +}; + +pub fn load_title(mut title: Signal>, message: Signal>) { + use_future(move || async move { + let result = crate::backend::endpoints::event_title() + .await + .inspect_err(|e| popup_error(message.clone(), format!("Hiba: {}", e))) + .ok(); + + title.set(result.unwrap_or_else(|| "Apollo esemény".into()).into()); + }); +} + +pub fn check_auth(mut auth: Signal, message: 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.clone(), format!("Üdv újra, {name}")); + } + }); +} + +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?; + 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(); + + let mut teams_sorted: Vec<_> = new_team_state.into_iter().collect(); + teams_sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0))); + + puzzles.set(puzzles_sorted); + teams_state.set(teams_sorted); + } + dioxus::Ok(()) + }); +} + +pub fn auto_hide_message(mut message: Signal>) { + use_effect(move || { + if message.read().is_some() { + spawn(async move { + gloo_timers::future::sleep(core::time::Duration::from_secs(5)).await; + message.set(None); + }); + } + }); +} diff --git a/src/app/models.rs b/src/app/models.rs index 7a33660..5fb4ad4 100644 --- a/src/app/models.rs +++ b/src/app/models.rs @@ -1,4 +1,4 @@ -#[derive(Default)] +#[derive(Default, Clone, PartialEq)] pub struct AuthState { pub(crate) username: String, pub(crate) password: String, diff --git a/src/app/utils.rs b/src/app/utils.rs index c8f5bad..c8c2e6b 100644 --- a/src/app/utils.rs +++ b/src/app/utils.rs @@ -8,7 +8,7 @@ use crate::{ pub fn parse_puzzle_csv( csv_text: &str, - message: &mut Signal>, + message: Signal>, ) -> PuzzleSolutions { let mut rdr = ReaderBuilder::new() .has_headers(true) @@ -61,7 +61,7 @@ pub fn parse_puzzle_csv( if volte { popup_error( - message, + message.clone(), "néhány sort nem sikerült betölteni, nézd meg a konzolt", ); } @@ -70,14 +70,14 @@ pub fn parse_puzzle_csv( } pub fn popup_error( - signal_message: &mut Signal>, + mut signal_message: Signal>, text: impl std::fmt::Display, ) { signal_message.set(Some((Message::MsgErr, text.to_string()))); } pub fn popup_normal( - signal_message: &mut Signal>, + mut signal_message: Signal>, text: impl std::fmt::Display, ) { signal_message.set(Some((Message::MsgNorm, text.to_string()))); diff --git a/src/components/input_section.rs b/src/components/input_section.rs new file mode 100644 index 0000000..6690932 --- /dev/null +++ b/src/components/input_section.rs @@ -0,0 +1,129 @@ +use dioxus::prelude::*; + +use crate::{ + app::{AuthState, Message, actions}, + backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, + components::select::*, +}; + +const BUTTON: &str = "ml-4 w-30 px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; +const INPUT: &str = "w-50 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"; +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"; + +#[component] +pub fn InputSection( + auth_current: AuthState, + auth: Signal, + message: Signal>, + puzzle_id: Signal, + puzzle_value: Signal, + puzzle_solution: Signal, + parsed_puzzles: Signal, + mut teams_state: Signal>, + puzzles: Signal>, +) -> Element { + let teams = teams_state.read(); + let ref_puzzles = puzzles.read(); + + let solved = teams + .iter() + .find(|(team, _)| team == &auth_current.username) + .map(|(_, solved)| solved); + + let puzzle_dropdown_options = solved.into_iter().flat_map(|solved| { + ref_puzzles + .iter() + .filter(|(id, _)| !solved.contains(id)) + .enumerate() + .map(|(i, (id, _))| { + rsx! { + SelectOption:: { + index: i, + value: id.clone(), + text_value: "{id}. feladat", + {format!("{id}. feladat")} + SelectItemIndicator {} + } + } + }) + }); + + rsx!( + if !auth_current.joined { + // Join form + input { class: INPUT, + r#type: "text", + placeholder: "Csapatnév", + value: "{auth_current.username}", + oninput: move |evt| auth.write().username = evt.value() + } + + if auth_current.show_password_prompt { + input { class: "ml-4 {INPUT}", + r#type: "password", + placeholder: "Admin jelszó", + value: "{auth_current.password}", + oninput: move |evt| auth.write().password = evt.value() + } + } + + button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles, teams_state), "Belépés" } + } else { + // Submit form + div { class: "input-flexy-boxy flex flex-row h-[50px]", + Select:: { + placeholder: "Feladat kiválasztása", + on_value_change: move |value: Option| { + if let Some(value) = value { + puzzle_id.set(value); + } + }, + SelectTrigger { + aria_label: "Select Trigger", + width: "12rem", + SelectValue {} + } + SelectList { + aria_label: "Select Demo", + SelectGroup { + {puzzle_dropdown_options} + } + } + } + + input { class: "ml-4 {INPUT}", + r#type: "text", + placeholder: "Megoldás", + value: "{puzzle_solution}", + oninput: move |evt| puzzle_solution.set(evt.value()) + } + + if auth_current.is_admin { + input { class: "ml-4 {INPUT}", + r#type: "text", + placeholder: "Érték/Nyeremény", + value: "{puzzle_value}", + oninput: move |evt| puzzle_value.set(evt.value()) + } + + input { class: "ml-4 {INPUT}", + r#type: "password", + placeholder: "Admin jelszó", + value: "{auth_current.password}", + oninput: move |evt| auth.write().password = evt.value() + } + + input { class: "ml-4 {CSV_INPUT}", + r#type: "file", + r#accept: ".csv", + onchange: actions::handle_csv(parsed_puzzles.clone(), message.clone()), + } + + button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles, teams_state), "Beállítás" } + } else { + button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles, teams_state), "Küldés" } + } + } + + }) +} From 52a74328e45415cddbe23212d8c9c1de5e208edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jeromos=20Kov=C3=A1cs?= Date: Fri, 12 Dec 2025 12:15:56 +0100 Subject: [PATCH 40/70] misc(makefile): quote help --- Makefile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 346c0f9..4ea865f 100644 --- a/Makefile +++ b/Makefile @@ -20,8 +20,8 @@ clean: cargo 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 clean: clean target + @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 "clean: clean target" From 2695f7b6c85ce39e243de537afdeac57fca9d3ee Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 13 Dec 2025 00:14:48 +0100 Subject: [PATCH 41/70] feat(client): support logout logic --- src/app.rs | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8c9a088..fe3a639 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,17 +1,19 @@ #![deny(clippy::unwrap_used)] #![forbid(unsafe_code)] -use dioxus::{fullstack::Lazy, prelude::*}; -// use dioxus_primitives::select::*; +use dioxus::prelude::*; pub mod actions; mod hooks; mod models; mod utils; +const BUTTON: &str = "ml-4 w-30 px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; + pub use crate::app::models::{AuthState, Message}; use crate::{ + app::utils::{popup_error, popup_normal}, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, components::{ input_section::InputSection, message_popup::MessagePopup, score_table::ScoreTable, @@ -31,8 +33,8 @@ pub fn App() -> Element { 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 mut auth = use_signal(AuthState::default); + let mut auth_current = auth.read(); let teams_state = use_signal(Vec::<(String, SolvedPuzzles)>::new); let puzzles = use_signal(Vec::<(PuzzleId, PuzzleValue)>::new); let message = use_signal(|| None::<(Message, String)>); @@ -42,7 +44,6 @@ pub fn App() -> Element { trace!("variables inited"); // side effect handlers - hooks::auto_hide_message(message); hooks::check_auth(auth, message); hooks::load_title(title, message); @@ -120,6 +121,25 @@ pub fn App() -> Element { points: points, } } + div { class: "mt-5", + button { class: "{BUTTON}", + onclick: move |_| async move { + match crate::backend::endpoints::logout().await { + Ok(_) => { + popup_normal(message.clone(), format!("Viszlát, {}", auth.read().username)); + auth.set(AuthState::default()); + } + Err(e) => { + popup_error( + message.clone(), + format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), + ); + } + } + }, + "Kijelentkezés" + } + } } // Message popup From d7ad2abcd8d89bb978de5a4c26ab944e34e3b60c Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 13 Dec 2025 00:17:27 +0100 Subject: [PATCH 42/70] misc(client): use native html select instead of dioxus' --- src/components/input_section.rs | 54 ++++------ src/components/mod.rs | 1 - src/components/select/component.rs | 116 --------------------- src/components/select/mod.rs | 2 - src/components/select/style.css | 156 ----------------------------- 5 files changed, 21 insertions(+), 308 deletions(-) delete mode 100644 src/components/select/component.rs delete mode 100644 src/components/select/mod.rs delete mode 100644 src/components/select/style.css diff --git a/src/components/input_section.rs b/src/components/input_section.rs index 6690932..02a5020 100644 --- a/src/components/input_section.rs +++ b/src/components/input_section.rs @@ -3,7 +3,6 @@ use dioxus::prelude::*; use crate::{ app::{AuthState, Message, actions}, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, - components::select::*, }; const BUTTON: &str = "ml-4 w-30 px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; @@ -30,23 +29,9 @@ pub fn InputSection( .find(|(team, _)| team == &auth_current.username) .map(|(_, solved)| solved); - let puzzle_dropdown_options = solved.into_iter().flat_map(|solved| { - ref_puzzles - .iter() - .filter(|(id, _)| !solved.contains(id)) - .enumerate() - .map(|(i, (id, _))| { - rsx! { - SelectOption:: { - index: i, - value: id.clone(), - text_value: "{id}. feladat", - {format!("{id}. feladat")} - SelectItemIndicator {} - } - } - }) - }); + let selectopts = solved + .into_iter() + .flat_map(|solved| ref_puzzles.iter().filter(|(id, _)| !solved.contains(id))); rsx!( if !auth_current.joined { @@ -71,23 +56,26 @@ pub fn InputSection( } else { // Submit form div { class: "input-flexy-boxy flex flex-row h-[50px]", - Select:: { - placeholder: "Feladat kiválasztása", - on_value_change: move |value: Option| { - if let Some(value) = value { - puzzle_id.set(value); + if !auth_current.is_admin { + select { + class: "{INPUT}", + onchange: move |evt: Event| { + debug!("{}", evt.value()); + puzzle_id.set(evt.value()); + }, + for (id, _) in selectopts { + option { + value: "{id}", + "{id}. feladat" + } } - }, - SelectTrigger { - aria_label: "Select Trigger", - width: "12rem", - SelectValue {} } - SelectList { - aria_label: "Select Demo", - SelectGroup { - {puzzle_dropdown_options} - } + } else { + input { class: "ml-4 {INPUT}", + r#type: "text", + placeholder: "Feladat", + value: "{puzzle_id}", + oninput: move |evt| puzzle_id.set(evt.value()) } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 20e6ee0..b198fcf 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -2,6 +2,5 @@ pub mod input_section; pub mod message_popup; pub mod score_table; -pub mod select; pub mod team_status; pub mod tooltip; diff --git a/src/components/select/component.rs b/src/components/select/component.rs deleted file mode 100644 index 88e70f0..0000000 --- a/src/components/select/component.rs +++ /dev/null @@ -1,116 +0,0 @@ -use dioxus::prelude::*; -use dioxus_primitives::select::{ - self, SelectGroupLabelProps, SelectGroupProps, SelectListProps, SelectOptionProps, SelectProps, - SelectTriggerProps, SelectValueProps, -}; - -#[component] -pub fn Select(props: SelectProps) -> Element { - rsx! { - document::Link { rel: "stylesheet", href: asset!("./style.css") } - select::Select { - class: "select", - value: props.value, - default_value: props.default_value, - on_value_change: props.on_value_change, - disabled: props.disabled, - name: props.name, - placeholder: props.placeholder, - roving_loop: props.roving_loop, - typeahead_timeout: props.typeahead_timeout, - attributes: props.attributes, - {props.children} - } - } -} - -#[component] -pub fn SelectTrigger(props: SelectTriggerProps) -> Element { - rsx! { - select::SelectTrigger { class: "select-trigger", attributes: props.attributes, - {props.children} - svg { - class: "select-expand-icon", - view_box: "0 0 24 24", - xmlns: "http://www.w3.org/2000/svg", - polyline { points: "6 9 12 15 18 9" } - } - } - } -} - -#[component] -pub fn SelectValue(props: SelectValueProps) -> Element { - rsx! { - select::SelectValue { attributes: props.attributes } - } -} - -#[component] -pub fn SelectList(props: SelectListProps) -> Element { - rsx! { - select::SelectList { - class: "select-list", - id: props.id, - attributes: props.attributes, - {props.children} - } - } -} - -#[component] -pub fn SelectGroup(props: SelectGroupProps) -> Element { - rsx! { - select::SelectGroup { - class: "select-group", - disabled: props.disabled, - id: props.id, - attributes: props.attributes, - {props.children} - } - } -} - -#[component] -pub fn SelectGroupLabel(props: SelectGroupLabelProps) -> Element { - rsx! { - select::SelectGroupLabel { - class: "select-group-label", - id: props.id, - attributes: props.attributes, - {props.children} - } - } -} - -#[component] -pub fn SelectOption(props: SelectOptionProps) -> Element { - rsx! { - select::SelectOption:: { - class: "select-option", - value: props.value, - text_value: props.text_value, - disabled: props.disabled, - id: props.id, - index: props.index, - aria_label: props.aria_label, - aria_roledescription: props.aria_roledescription, - attributes: props.attributes, - {props.children} - } - } -} - -#[component] -pub fn SelectItemIndicator() -> Element { - rsx! { - select::SelectItemIndicator { - svg { - class: "select-check-icon", - view_box: "0 0 24 24", - xmlns: "http://www.w3.org/2000/svg", - path { d: "M5 13l4 4L19 7" } - } - } - } -} diff --git a/src/components/select/mod.rs b/src/components/select/mod.rs deleted file mode 100644 index 9a8ae55..0000000 --- a/src/components/select/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod component; -pub use component::*; \ No newline at end of file diff --git a/src/components/select/style.css b/src/components/select/style.css deleted file mode 100644 index 7fe5d87..0000000 --- a/src/components/select/style.css +++ /dev/null @@ -1,156 +0,0 @@ -.select { - position: relative; -} - -.select-trigger { - position: relative; - display: flex; - box-sizing: border-box; - flex-direction: row; - align-items: center; - justify-content: space-between; - padding: 0.25rem; - padding: 8px 12px; - border: none; - border-radius: 0.5rem; - border-radius: calc(0.5rem); - background: none; - /* background: var(--light, var(--primary-color)) */ - /* var(--dark, var(--primary-color-3)); */ - background: white; - box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) - var(--dark, var(--primary-color-7)); - color: var(--secondary-color-4); - cursor: pointer; - gap: 0.25rem; - transition: background-color 100ms ease-out; -} - -.select-trigger span[data-placeholder="true"] { - color: var(--secondary-color-5); -} - -.select[data-state="open"] .select-trigger { - pointer-events: none; -} - -.select-expand-icon { - width: 20px; - height: 20px; - fill: none; - stroke: var(--primary-color-7); - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 2; -} - -.select-check-icon { - width: 1rem; - height: 1rem; - fill: none; - stroke: var(--secondary-color-5); - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 2; -} - -.select[data-disabled="true"] .select-trigger { - color: var(--secondary-color-5); - cursor: not-allowed; -} - -.select-trigger:hover:not([data-disabled="true"]), -.select-trigger:focus-visible { - background: var(--light, var(--primary-color-4)) - var(--dark, var(--primary-color-5)); - color: var(--secondary-color-1); - outline: none; -} - -.select-list { - position: absolute; - z-index: 1000; - top: 100%; - left: 0; - min-width: 100%; - box-sizing: border-box; - padding: 0.25rem; - border-radius: 0.5rem; - margin-top: 0.25rem; - background: var(--light, var(--primary-color)) - var(--dark, var(--primary-color-5)); - box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) - var(--dark, var(--primary-color-7)); - opacity: 0; - pointer-events: none; - transform-origin: top; - will-change: transform, opacity; -} - -.select-list[data-state="closed"] { - animation: select-list-animate-out 150ms ease-in forwards; - pointer-events: none; -} - -@keyframes select-list-animate-out { - 0% { - opacity: 1; - transform: scale(1) translateY(0); - } - - 100% { - opacity: 0; - transform: scale(0.95) translateY(-2px); - } -} - -.select-list[data-state="open"] { - animation: select-list-animate-in 150ms ease-out forwards; - pointer-events: auto; -} - -@keyframes select-list-animate-in { - 0% { - opacity: 0; - transform: scale(0.95) translateY(-2px); - } - - 100% { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -.select-option { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - border-radius: calc(0.5rem - 0.25rem); - cursor: pointer; - font-size: 14px; -} - -.select-option[data-disabled="true"] { - color: var(--secondary-color-5); - cursor: not-allowed; -} - -.select-option:hover:not([data-disabled="true"]), -.select-option:focus-visible { - background: var(--light, var(--primary-color-4)) - var(--dark, var(--primary-color-7)); - color: var(--secondary-color-1); - outline: none; -} - -.select-group-label { - padding: 4px 12px; - color: var(--secondary-color-5); - font-size: 0.75rem; -} - -[data-disabled="true"] { - cursor: not-allowed; - opacity: 0.5; -} From a436f215444eb296f2d530b27301fcd09f5c56bc Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 13 Dec 2025 00:22:21 +0100 Subject: [PATCH 43/70] misc(client): wait for server to set the state (revert) --- src/app/actions.rs | 6 ------ src/components/input_section.rs | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index 83f8f1d..c7d1f83 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -148,18 +148,12 @@ pub fn handle_action( puzzle_value: Signal, puzzle_solution: Signal, parsed_puzzles: Signal, - mut teams_state: Signal>, ) -> impl FnMut(Event) + 'static { move |_| { spawn(async move { trace!("action handler called"); if !auth.read().joined { self::handle_join(auth, message).await; - if auth.read().joined { - teams_state - .write() - .push((auth.read().username.clone(), SolvedPuzzles::new())); - } } else if auth.read().is_admin { self::handle_admin_submit( puzzle_id, diff --git a/src/components/input_section.rs b/src/components/input_section.rs index 02a5020..0ddde3e 100644 --- a/src/components/input_section.rs +++ b/src/components/input_section.rs @@ -52,7 +52,7 @@ pub fn InputSection( } } - button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles, teams_state), "Belépés" } + button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Belépés" } } else { // Submit form div { class: "input-flexy-boxy flex flex-row h-[50px]", @@ -107,9 +107,9 @@ pub fn InputSection( onchange: actions::handle_csv(parsed_puzzles.clone(), message.clone()), } - button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles, teams_state), "Beállítás" } + button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Beállítás" } } else { - button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles, teams_state), "Küldés" } + button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Küldés" } } } From 9ca3e8d76610834ffb287c9c3e525977f910ad96 Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 13 Dec 2025 00:23:25 +0100 Subject: [PATCH 44/70] fix(client): dont allow empty names + small code improvments --- src/app/actions.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index c7d1f83..7c62c66 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -17,29 +17,35 @@ async fn check_admin_username(username: String) -> Result { pub async fn handle_join(mut auth: Signal, message: Signal>) { let u = auth.read().username.clone(); + if u.trim().is_empty() { + popup_error(message, "A csapatnév mező nem lehet üres"); + return; + } + if check_admin_username(u.clone()).await.is_ok_and(|x| x) { auth.write().is_admin = true; auth.write().show_password_prompt = true; // If password is empty, don't proceed yet if auth.read().password.is_empty() { - popup_normal(message.clone(), "Adja meg az admin jelszót"); + popup_normal(message, "Adja meg az admin jelszót"); return; } auth.write().joined = true; return; }; - match crate::backend::endpoints::join(u.clone()).await { - Ok(_) => { - popup_normal(message.clone(), format!("Üdv, {}", u)); + let _ok_none = crate::backend::endpoints::join(u.clone()).await; + match crate::backend::endpoints::auth_state().await { + Ok(uname) => { + popup_normal(message, format!("Üdv, {}", uname)); auth.write().joined = true; auth.write().password = String::new(); auth.write().show_password_prompt = false; } Err(e) => { popup_error( - message.clone(), + message, format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), ); } From f09d982f048f565ab29d370dfb1429604a2b426d Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 13 Dec 2025 02:22:19 +0100 Subject: [PATCH 45/70] misc(client): proper handler + stlye for logout button --- src/app.rs | 27 +++++++-------------------- src/app/actions.rs | 27 ++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/app.rs b/src/app.rs index fe3a639..da9896f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,12 +8,12 @@ mod hooks; mod models; mod utils; -const BUTTON: &str = "ml-4 w-30 px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; +const BUTTON: &str = "w-30 px-3 py-2 rounded-lg border border-red-900 bg-red-400 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition"; pub use crate::app::models::{AuthState, Message}; use crate::{ - app::utils::{popup_error, popup_normal}, + app::actions::handle_logout, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, components::{ input_section::InputSection, message_popup::MessagePopup, score_table::ScoreTable, @@ -33,8 +33,8 @@ pub fn App() -> Element { let puzzle_id = use_signal(String::new); let puzzle_solution = use_signal(String::new); let puzzle_value = use_signal(String::new); - let mut auth = use_signal(AuthState::default); - let mut auth_current = auth.read(); + 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 message = use_signal(|| None::<(Message, String)>); @@ -102,7 +102,6 @@ pub fn App() -> Element { // Input section div { class: "input-section", InputSection { - auth_current: auth_current.clone(), auth, message, puzzle_id, @@ -114,7 +113,7 @@ pub fn App() -> Element { } } // div: input-section - if auth_current.joined { + if auth_current.joined && !auth_current.is_admin{ div { class: "mt-5", TeamStatus { team: auth_current.username.clone(), @@ -123,20 +122,8 @@ pub fn App() -> Element { } div { class: "mt-5", button { class: "{BUTTON}", - onclick: move |_| async move { - match crate::backend::endpoints::logout().await { - Ok(_) => { - popup_normal(message.clone(), format!("Viszlát, {}", auth.read().username)); - auth.set(AuthState::default()); - } - Err(e) => { - popup_error( - message.clone(), - format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), - ); - } - } - }, + onclick: handle_logout(auth, message), // TODO support wipe logout + cursor: "pointer", "Kijelentkezés" } } diff --git a/src/app/actions.rs b/src/app/actions.rs index 7c62c66..0f12077 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -5,7 +5,7 @@ use crate::{ models::{AuthState, Message}, utils::{parse_puzzle_csv, popup_error, popup_normal}, }, - backend::models::{Puzzle, PuzzleSolutions, SolvedPuzzles}, + backend::models::{Puzzle, PuzzleSolutions}, }; // TODO could be handeled in much better ways @@ -176,3 +176,28 @@ pub fn handle_action( }); } } + +pub fn handle_logout( + mut auth: Signal, + message: Signal>, +) -> impl FnMut(Event) + 'static { + move |_| { + spawn(async move { + match crate::backend::endpoints::logout(None::).await { + Ok(_) => { + popup_normal( + message.clone(), + format!("Viszlát, {}", auth.read().username), + ); + auth.set(AuthState::default()); + } + Err(e) => { + popup_error( + message.clone(), + format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), + ); + } + } + }); + } +} From ca7ef445f21a2dede8058fafe4426596e24848c9 Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 13 Dec 2025 02:23:13 +0100 Subject: [PATCH 46/70] fix(client): use proper cursors --- src/backend/endpoints.rs | 2 +- src/components/input_section.rs | 17 ++++++++++---- src/components/score_table.rs | 41 +++++++++++++++++---------------- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/backend/endpoints.rs b/src/backend/endpoints.rs index fa061f8..01da4d2 100644 --- a/src/backend/endpoints.rs +++ b/src/backend/endpoints.rs @@ -88,7 +88,7 @@ pub async fn join(username: String) -> Result, HttpError> { /// returns empty, expired `sid` `SetCookie` header => browser deletes the valid one => user's now deauthed /// /// WARN: **always** returns `Some(Ok(SetHeader { data: None }))`, see -#[get("/api/logout", cookies: TypedHeader)] +#[post("/api/logout", cookies: TypedHeader)] pub async fn logout(wipe_progress: Option) -> Result, HttpError> { let uuid = extract_sid_cookie(cookies) .await diff --git a/src/components/input_section.rs b/src/components/input_section.rs index 0ddde3e..1520584 100644 --- a/src/components/input_section.rs +++ b/src/components/input_section.rs @@ -11,7 +11,6 @@ const CSV_INPUT: &str = "w-70 px-3 py-2 rounded-lg border border-gray-300 bg-gra #[component] pub fn InputSection( - auth_current: AuthState, auth: Signal, message: Signal>, puzzle_id: Signal, @@ -21,6 +20,7 @@ pub fn InputSection( mut teams_state: Signal>, puzzles: Signal>, ) -> Element { + let auth_current = auth.read().clone(); let teams = teams_state.read(); let ref_puzzles = puzzles.read(); @@ -40,6 +40,7 @@ pub fn InputSection( r#type: "text", placeholder: "Csapatnév", value: "{auth_current.username}", + cursor: "text", oninput: move |evt| auth.write().username = evt.value() } @@ -48,23 +49,26 @@ pub fn InputSection( r#type: "password", placeholder: "Admin jelszó", value: "{auth_current.password}", + cursor: "text", oninput: move |evt| auth.write().password = evt.value() } } - button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Belépés" } + button { class: BUTTON, cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Belépés" } } else { // Submit form div { class: "input-flexy-boxy flex flex-row h-[50px]", if !auth_current.is_admin { select { class: "{INPUT}", + cursor: "pointer", onchange: move |evt: Event| { debug!("{}", evt.value()); puzzle_id.set(evt.value()); }, for (id, _) in selectopts { option { + cursor: "pointer", value: "{id}", "{id}. feladat" } @@ -75,6 +79,7 @@ pub fn InputSection( r#type: "text", placeholder: "Feladat", value: "{puzzle_id}", + cursor: "text", oninput: move |evt| puzzle_id.set(evt.value()) } } @@ -83,6 +88,7 @@ pub fn InputSection( r#type: "text", placeholder: "Megoldás", value: "{puzzle_solution}", + cursor: "text", oninput: move |evt| puzzle_solution.set(evt.value()) } @@ -91,6 +97,7 @@ pub fn InputSection( r#type: "text", placeholder: "Érték/Nyeremény", value: "{puzzle_value}", + cursor: "text", oninput: move |evt| puzzle_value.set(evt.value()) } @@ -98,18 +105,20 @@ pub fn InputSection( r#type: "password", placeholder: "Admin jelszó", value: "{auth_current.password}", + cursor: "text", oninput: move |evt| auth.write().password = evt.value() } input { class: "ml-4 {CSV_INPUT}", r#type: "file", r#accept: ".csv", + cursor: "pointer", onchange: actions::handle_csv(parsed_puzzles.clone(), message.clone()), } - button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Beállítás" } + button { class: BUTTON, cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Beállítás" } } else { - button { class: BUTTON, onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Küldés" } + button { class: BUTTON, cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Küldés" } } } diff --git a/src/components/score_table.rs b/src/components/score_table.rs index 1ec190e..ebc7d39 100644 --- a/src/components/score_table.rs +++ b/src/components/score_table.rs @@ -15,32 +15,33 @@ pub fn ScoreTable( rsx! { table { class: "", onclick: toggle_fullscreen, + cursor: "pointer", thead { tr { if !puzzles.read().is_empty() || !teams_state.read().is_empty() { th { class: "text-left pl-2", "." } - } - for (id, value) in puzzles.read().iter() { - th { - span { class: "text-md", - "{id}" - } - br { } - span { class: "text-sm", - "({value} pont)" - } + for (id, value) in puzzles.read().iter() { + th { + span { class: "text-md", + "{id}" + } + br { } + span { class: "text-sm", + "({value} pont)" + } - // Tooltip { - // TooltipTrigger { class: "text-(--light)", "{id}" } + // 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}" - // } - // } - // } + // TooltipContent { + // side: ContentSide::Top, + // align: ContentAlign::Center, + // div { class: "p-2 border border-(--dark2) rounded-md bg-(--dark)", + // "value: {value}" + // } + // } + // } + } } } } From 7c4c2a24ed61e50475728e15034379b86979bad3 Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 13 Dec 2025 02:47:02 +0100 Subject: [PATCH 47/70] fix(client): simpler admincheck, dropdown initial data --- src/app/actions.rs | 10 +++++----- src/components/input_section.rs | 5 +++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index 0f12077..5cccbfb 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -8,11 +8,11 @@ use crate::{ backend::models::{Puzzle, PuzzleSolutions}, }; -// TODO could be handeled in much better ways -async fn check_admin_username(username: String) -> Result { +// TODO could be handeled better +fn check_admin_username(username: String) -> bool { // use std::env; - let admin_username = "jani"; - Ok(username == admin_username) + let admin_username = "admin"; + username == admin_username } pub async fn handle_join(mut auth: Signal, message: Signal>) { @@ -22,7 +22,7 @@ pub async fn handle_join(mut auth: Signal, message: Signal Date: Sat, 13 Dec 2025 10:14:13 +0100 Subject: [PATCH 48/70] fix(client): enable desktop build --- Cargo.toml | 6 +++--- src/app/hooks.rs | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d0d7a2a..5bbd7f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ edition = "2024" [dependencies] csv = "1.4.0" -dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false, optional = true } +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 } @@ -21,8 +21,8 @@ uuid = { version = "1.19.0", features = ["v4", "serde"], optional = true } [features] default = ["web"] -web = ["dioxus/web", "dep:gloo-timers", "dep:dioxus-primitives"] -# desktop = ["dioxus/desktop"] +web = ["dioxus/web", "dep:gloo-timers"] +desktop = ["dioxus/desktop", "dep:tokio"] # mobile = ["dioxus/mobile"] server = ["dioxus/server", "dep:tokio", "dep:secrecy", "dep:uuid"] # save server state diff --git a/src/app/hooks.rs b/src/app/hooks.rs index 3d1981d..b6ba4a0 100644 --- a/src/app/hooks.rs +++ b/src/app/hooks.rs @@ -53,7 +53,11 @@ pub fn auto_hide_message(mut message: Signal>) { use_effect(move || { if message.read().is_some() { spawn(async move { + #[cfg(feature = "web")] gloo_timers::future::sleep(core::time::Duration::from_secs(5)).await; + #[cfg(feature = "desktop")] + tokio::time::sleep(core::time::Duration::from_secs(5)).await; + message.set(None); }); } From 252f945529927df5e1098d2a204a9ec061ff2cb8 Mon Sep 17 00:00:00 2001 From: csboo Date: Sat, 13 Dec 2025 11:43:18 +0100 Subject: [PATCH 49/70] chore(client): remove 14 unneded clones --- src/app.rs | 2 +- src/app/actions.rs | 19 ++++++++----------- src/app/hooks.rs | 4 ++-- src/app/utils.rs | 2 +- src/components/input_section.rs | 2 +- 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/app.rs b/src/app.rs index da9896f..a8ec50d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,7 +93,7 @@ pub fn App() -> Element { ScoreTable { puzzles: puzzles, teams_state: teams_state, - toggle_fullscreen: actions::toggle_fullscreen(is_fullscreen.clone()), + toggle_fullscreen: actions::toggle_fullscreen(is_fullscreen), } } // div: table-container diff --git a/src/app/actions.rs b/src/app/actions.rs index 5cccbfb..5141cd7 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -61,12 +61,12 @@ pub async fn handle_user_submit( let solution_current = puzzle_solution.read().clone(); match crate::backend::endpoints::submit_solution(puzzle_current, solution_current).await { Ok(msg) => { - popup_normal(message.clone(), msg); + popup_normal(message, msg); puzzle_id.set(String::new()); puzzle_solution.set(String::new()); } Err(e) => { - popup_error(message.clone(), format!("Hiba: {}", e)); + popup_error(message, format!("Hiba: {}", e)); } } } @@ -84,7 +84,7 @@ pub async fn handle_admin_submit( if parsed_puzzles.read().is_empty() { debug!("parsed puzzles is empty, trying from manual values"); let Ok(value_current) = puzzle_value.read().parse() else { - popup_error(message.clone(), "Az érték csak szám lehet"); + popup_error(message, "Az érték csak szám lehet"); return; }; PuzzleSolutions::from([( @@ -102,7 +102,7 @@ pub async fn handle_admin_submit( .await { Ok(msg) => { - popup_normal(message.clone(), msg); + popup_normal(message, msg); puzzle_id.set(String::new()); puzzle_solution.set(String::new()); puzzle_value.set(String::new()); @@ -110,7 +110,7 @@ pub async fn handle_admin_submit( } Err(e) => { popup_error( - message.clone(), + message, format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), ); } @@ -128,7 +128,7 @@ pub fn handle_csv( warn!("couldn't parse text from selected file"); return; }; - parsed_puzzles.set(parse_puzzle_csv(&text, message.clone())); + parsed_puzzles.set(parse_puzzle_csv(&text, message)); debug!("set puzzles from csv"); } else { warn!("couldn't read selected file"); @@ -185,15 +185,12 @@ pub fn handle_logout( spawn(async move { match crate::backend::endpoints::logout(None::).await { Ok(_) => { - popup_normal( - message.clone(), - format!("Viszlát, {}", auth.read().username), - ); + popup_normal(message, format!("Viszlát, {}", auth.read().username)); auth.set(AuthState::default()); } Err(e) => { popup_error( - message.clone(), + message, format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), ); } diff --git a/src/app/hooks.rs b/src/app/hooks.rs index b6ba4a0..80dfec2 100644 --- a/src/app/hooks.rs +++ b/src/app/hooks.rs @@ -12,7 +12,7 @@ pub fn load_title(mut title: Signal>, message: Signal, message: Signal Date: Sat, 13 Dec 2025 16:19:52 +0100 Subject: [PATCH 50/70] fix(client): ui improvments, flex, scrolltable --- Makefile | 2 +- src/app.rs | 4 +-- src/components/input_section.rs | 53 ++++++++++++++++----------------- 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/Makefile b/Makefile index 6d6ec78..a2eafcc 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ APOLLO_STATE_PATH?=apollo-state.cbor.encrypted APOLLO_EVENT_TITLE?=hack-a-polo APOLLO_MESTER_JELSZO?=Password -dx-args:=@server --server --features server_state_save @client --web +dx-args:=@server --server --features server_state_save,web @client --web serve: APOLLO_STATE_PATH=${APOLLO_STATE_PATH} APOLLO_EVENT_TITLE=${APOLLO_EVENT_TITLE} APOLLO_MESTER_JELSZO=${APOLLO_MESTER_JELSZO} dx serve ${dx-args} diff --git a/src/app.rs b/src/app.rs index a8ec50d..76d92b8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -89,7 +89,7 @@ pub fn App() -> Element { } // div: other-container - div { class: "table-container mt-5", + div { class: "table-container mt-5 overflow-x-auto", style: "-webkit-overflow-scrolling: touch;", ScoreTable { puzzles: puzzles, teams_state: teams_state, @@ -100,7 +100,7 @@ pub fn App() -> Element { div { class: "others-container mt-5", if title.read().as_ref().is_some_and(|t| !t.is_empty()) { // Input section - div { class: "input-section", + div { class: "input-section relative input-flexy-boxy flex flex-wrap gap-3 flex-row", InputSection { auth, message, diff --git a/src/components/input_section.rs b/src/components/input_section.rs index 5b43972..8ad9255 100644 --- a/src/components/input_section.rs +++ b/src/components/input_section.rs @@ -5,8 +5,8 @@ use crate::{ backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, }; -const BUTTON: &str = "ml-4 w-30 px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; -const INPUT: &str = "w-50 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"; +const BUTTON: &str = "w-30 h-[50px] px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; +const INPUT: &str = "w-50 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"; 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"; #[component] @@ -40,29 +40,28 @@ pub fn InputSection( 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: "ml-4 {INPUT}", - r#type: "password", - placeholder: "Admin jelszó", - value: "{auth_current.password}", + // Join form + input { class: INPUT, + r#type: "text", + placeholder: "Csapatnév", + value: "{auth_current.username}", cursor: "text", - oninput: move |evt| auth.write().password = evt.value() + oninput: move |evt| auth.write().username = evt.value() } - } - button { class: BUTTON, cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Belépés" } - } else { + 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, cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Belépés" } + } else { // Submit form - div { class: "input-flexy-boxy flex flex-row h-[50px]", if !auth_current.is_admin { select { class: "{INPUT}", @@ -80,7 +79,7 @@ pub fn InputSection( } } } else { - input { class: "ml-4 {INPUT}", + input { class: "{INPUT}", r#type: "text", placeholder: "Feladat", value: "{puzzle_id}", @@ -89,7 +88,7 @@ pub fn InputSection( } } - input { class: "ml-4 {INPUT}", + input { class: "{INPUT}", r#type: "text", placeholder: "Megoldás", value: "{puzzle_solution}", @@ -98,7 +97,7 @@ pub fn InputSection( } if auth_current.is_admin { - input { class: "ml-4 {INPUT}", + input { class: "{INPUT}", r#type: "text", placeholder: "Érték/Nyeremény", value: "{puzzle_value}", @@ -106,7 +105,7 @@ pub fn InputSection( oninput: move |evt| puzzle_value.set(evt.value()) } - input { class: "ml-4 {INPUT}", + input { class: "{INPUT}", r#type: "password", placeholder: "Admin jelszó", value: "{auth_current.password}", @@ -114,7 +113,7 @@ pub fn InputSection( oninput: move |evt| auth.write().password = evt.value() } - input { class: "ml-4 {CSV_INPUT}", + input { class: "{CSV_INPUT}", r#type: "file", r#accept: ".csv", cursor: "pointer", @@ -125,7 +124,5 @@ pub fn InputSection( } else { button { class: BUTTON, cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Küldés" } } - } - }) } From 77e092104f21de33818d0489e244257e5f810113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jeromos=20Kov=C3=A1cs?= Date: Sun, 14 Dec 2025 14:54:21 +0100 Subject: [PATCH 51/70] chore(make): don't include unnecessary web feature for server --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a2eafcc..6d6ec78 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ APOLLO_STATE_PATH?=apollo-state.cbor.encrypted APOLLO_EVENT_TITLE?=hack-a-polo APOLLO_MESTER_JELSZO?=Password -dx-args:=@server --server --features server_state_save,web @client --web +dx-args:=@server --server --features server_state_save @client --web serve: APOLLO_STATE_PATH=${APOLLO_STATE_PATH} APOLLO_EVENT_TITLE=${APOLLO_EVENT_TITLE} APOLLO_MESTER_JELSZO=${APOLLO_MESTER_JELSZO} dx serve ${dx-args} From f09e6eb524c5ee1d20edf10e4bfa951e1666a9d0 Mon Sep 17 00:00:00 2001 From: csboo Date: Sun, 14 Dec 2025 17:38:00 +0100 Subject: [PATCH 52/70] fix(client): fix dropdown value selection (proper default value) --- src/components/input_section.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/input_section.rs b/src/components/input_section.rs index 8ad9255..7092ef0 100644 --- a/src/components/input_section.rs +++ b/src/components/input_section.rs @@ -1,4 +1,4 @@ -use dioxus::prelude::*; +use dioxus::{html::textarea::placeholder, prelude::*}; use crate::{ app::{AuthState, Message, actions}, @@ -33,11 +33,6 @@ pub fn InputSection( .into_iter() .flat_map(|solved| ref_puzzles.iter().filter(|(id, _)| !solved.contains(id))); - // needed for dropdown to have initial value - if let Some(firstvalid) = selectopts.clone().next().map(|(id, _)| id) { - puzzle_id.set(firstvalid.to_string()); - } - rsx!( if !auth_current.joined { // Join form @@ -70,6 +65,9 @@ pub fn InputSection( 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", From a80549072a577bb532be5ca3515905b35b001e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jeromos=20Kov=C3=A1cs?= Date: Sun, 14 Dec 2025 18:58:39 +0100 Subject: [PATCH 53/70] chore(make): make bundling easier --- Makefile | 21 +++- src/components/mod.rs | 1 - src/components/tooltip/component.rs | 44 -------- src/components/tooltip/mod.rs | 2 - src/components/tooltip/style.css | 150 ---------------------------- 5 files changed, 18 insertions(+), 200 deletions(-) delete mode 100644 src/components/tooltip/component.rs delete mode 100644 src/components/tooltip/mod.rs delete mode 100644 src/components/tooltip/style.css diff --git a/Makefile b/Makefile index 6d6ec78..3b8d27e 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,21 @@ serve-no-state: build: dx bundle ${dx-args} -bundle: +web-bundle: 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: + ulimit -n 1024 # needed on macos 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 @@ -22,6 +35,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/src/components/mod.rs b/src/components/mod.rs index b198fcf..ce44d2a 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -3,4 +3,3 @@ pub mod input_section; pub mod message_popup; pub mod score_table; pub mod team_status; -pub mod tooltip; diff --git a/src/components/tooltip/component.rs b/src/components/tooltip/component.rs deleted file mode 100644 index a6486d2..0000000 --- a/src/components/tooltip/component.rs +++ /dev/null @@ -1,44 +0,0 @@ -use dioxus::prelude::*; -use dioxus_primitives::tooltip::{self, TooltipContentProps, TooltipProps, TooltipTriggerProps}; - -#[component] -pub fn Tooltip(props: TooltipProps) -> Element { - rsx! { - document::Link { rel: "stylesheet", href: asset!("./style.css") } - tooltip::Tooltip { - class: "tooltip", - disabled: props.disabled, - open: props.open, - default_open: props.default_open, - on_open_change: props.on_open_change, - attributes: props.attributes, - {props.children} - } - } -} - -#[component] -pub fn TooltipTrigger(props: TooltipTriggerProps) -> Element { - rsx! { - tooltip::TooltipTrigger { - class: "tooltip-trigger", - id: props.id, - attributes: props.attributes, - {props.children} - } - } -} - -#[component] -pub fn TooltipContent(props: TooltipContentProps) -> Element { - rsx! { - tooltip::TooltipContent { - class: "tooltip-content", - id: props.id, - side: props.side, - align: props.align, - attributes: props.attributes, - {props.children} - } - } -} diff --git a/src/components/tooltip/mod.rs b/src/components/tooltip/mod.rs deleted file mode 100644 index 9a8ae55..0000000 --- a/src/components/tooltip/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod component; -pub use component::*; \ No newline at end of file diff --git a/src/components/tooltip/style.css b/src/components/tooltip/style.css deleted file mode 100644 index 0388ab8..0000000 --- a/src/components/tooltip/style.css +++ /dev/null @@ -1,150 +0,0 @@ -/* Tooltip Styles */ -.tooltip { - position: relative; - display: inline-block; -} - -.tooltip-trigger { - display: inline-block; -} - -.tooltip-content { - position: absolute; - z-index: 1000; - max-width: 250px; - padding: 8px 12px; - border-radius: 0.5rem; - animation: tooltip-fade-in 0.2s ease-in-out; - background-color: var(--secondary-color-4); - color: var(--primary-color); - font-size: 14px; - line-height: 1.4; -} - -.tooltip-content::after { - position: absolute; - border-width: 0.25rem; - border-style: solid; - margin-left: -0.25rem; - content: " "; - rotate: 45deg; -} - -/* Positioning based on side */ -.tooltip-content[data-side="top"] { - position: absolute; - bottom: 100%; - left: 50%; - margin-bottom: 8px; - transform: translateX(-50%); -} - -.tooltip-content[data-side="top"]::after { - top: calc(100% - 0.25rem); - left: 50%; - border-color: var(--secondary-color-4); - border-radius: 0 0 0.1rem; -} - -.tooltip-content[data-side="right"] { - position: absolute; - top: 50%; - left: 100%; - margin-left: 8px; - transform: translateY(-50%); -} - -.tooltip-content[data-side="right"]::after { - top: calc(50% - 0.25rem); - left: 0; - border-color: var(--secondary-color-4); - border-radius: 0 0 0 0.1rem; -} - -.tooltip-content[data-side="bottom"] { - position: absolute; - top: 100%; - left: 50%; - margin-top: 8px; - transform: translateX(-50%); -} - -.tooltip-content[data-side="bottom"]::after { - bottom: calc(100% - 0.25rem); - left: 50%; - border-color: var(--secondary-color-4); - border-radius: 0.1rem 0 0; -} - -.tooltip-content[data-side="left"] { - position: absolute; - top: 50%; - right: 100%; - margin-right: 8px; - transform: translateY(-50%); -} - -.tooltip-content[data-side="left"]::after { - top: calc(50% - 0.25rem); - right: -0.25rem; - border-color: var(--secondary-color-4); - border-radius: 0 0.1rem 0 0; -} - -/* Alignment styles for top and bottom */ -.tooltip-content[data-side="top"][data-align="start"], -.tooltip-content[data-side="bottom"][data-align="start"] { - left: 0; - transform: none; -} - -.tooltip-content[data-side="top"][data-align="end"], -.tooltip-content[data-side="bottom"][data-align="end"] { - right: 0; - left: auto; - transform: none; -} - -/* Alignment styles for left and right */ -.tooltip-content[data-side="left"][data-align="start"], -.tooltip-content[data-side="right"][data-align="start"] { - top: 0; - transform: none; -} - -.tooltip-content[data-side="left"][data-align="center"], -.tooltip-content[data-side="right"][data-align="center"] { - top: 50%; - transform: translateY(-50%); -} - -.tooltip-content[data-side="left"][data-align="end"], -.tooltip-content[data-side="right"][data-align="end"] { - top: auto; - bottom: 0; - transform: none; -} - -/* Animation */ -@keyframes tooltip-fade-in { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -/* State styles */ -.tooltip[data-disabled="true"] .tooltip-trigger { - cursor: default; -} - -.tooltip-content[data-state="closed"] { - display: none; -} - -.tooltip-content[data-state="open"] { - display: block; -} From 561db223a97acf89065dbc88e75c106deac6b8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jeromos=20Kov=C3=A1cs?= Date: Sun, 14 Dec 2025 19:59:28 +0100 Subject: [PATCH 54/70] misc(backend): tiny communication improvements --- assets/translations.yml | 6 +++--- src/backend/logic.rs | 4 ++-- src/main.rs | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/assets/translations.yml b/assets/translations.yml index 1a29688..b5e0ce5 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" @@ -102,7 +102,7 @@ puzzle-already-solved: en: already solved this puzzle hu: ezt a feladatot már megoldottad puzzle-submit-success: - en: all right, succesfully saved your solution! + en: all right, successfully saved your solution! hu: hurrá, sikeresen elmentettük a megoldásod! # internal server error diff --git a/src/backend/logic.rs b/src/backend/logic.rs index 1b0df42..a7fce05 100644 --- a/src/backend/logic.rs +++ b/src/backend/logic.rs @@ -20,11 +20,11 @@ pub(super) static TEAMS: LazyLock> = /// without `key`, the app won't run fn ensure_env_var(key: &str) -> String { let Ok(value) = env::var(key) else { - error!("nincs beállítva a {key:?} környezeti változó, feladjuk"); + error!("nincs beállítva a(z) {key:?} környezeti változó, feladjuk"); process::exit(1); }; if value.is_empty() { - error!("a {key:?} környezeti változó üres, feladjuk"); + error!("a(z) {key:?} környezeti változó üres, feladjuk"); process::exit(1); } value diff --git a/src/main.rs b/src/main.rs index 86553ac..5957c63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,11 +7,11 @@ fn main() { #[cfg(feature = "server")] dioxus::serve(|| async move { - use dioxus::cli_config as dxconf; + use dioxus::cli_config::fullstack_address_or_localhost as dx_server_addr; use dioxus::prelude::*; backend::prepare_startup().await; - info!("serving on {}", dxconf::fullstack_address_or_localhost()); + info!("serving on http://{}", dx_server_addr()); let router = dioxus::server::router(app::App); Ok(router) From d3a60ea6c4cb7648de4a5ad515c048f92dd41287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jeromos=20Kov=C3=A1cs?= Date: Sun, 14 Dec 2025 19:59:28 +0100 Subject: [PATCH 55/70] misc(backend): tiny communication improvements --- src/app/actions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index 5141cd7..3777fd2 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -8,7 +8,7 @@ use crate::{ backend::models::{Puzzle, PuzzleSolutions}, }; -// TODO could be handeled better +// TODO could be handled better fn check_admin_username(username: String) -> bool { // use std::env; let admin_username = "admin"; From e11d4596fb57477ba58d9117af7cecc98e8b8edf Mon Sep 17 00:00:00 2001 From: csboo Date: Mon, 15 Dec 2025 02:06:31 +0100 Subject: [PATCH 56/70] chore(client): fn to sum team's points + use in table order --- src/app.rs | 1 + src/app/hooks.rs | 8 ++++++-- src/app/utils.rs | 10 +++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index 76d92b8..9f99730 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,7 @@ mod models; mod utils; const BUTTON: &str = "w-30 px-3 py-2 rounded-lg border border-red-900 bg-red-400 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition"; +pub mod utils; pub use crate::app::models::{AuthState, Message}; diff --git a/src/app/hooks.rs b/src/app/hooks.rs index 80dfec2..ab392b7 100644 --- a/src/app/hooks.rs +++ b/src/app/hooks.rs @@ -3,7 +3,7 @@ use dioxus::prelude::*; use crate::{ app::{ AuthState, Message, - utils::{popup_error, popup_normal}, + utils::{get_points_of, popup_error, popup_normal}, }, backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, }; @@ -40,7 +40,11 @@ pub fn subscribe_stream( puzzles_sorted.sort(); let mut teams_sorted: Vec<_> = new_team_state.into_iter().collect(); - teams_sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0))); + 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); diff --git a/src/app/utils.rs b/src/app/utils.rs index e938fd6..4b56fb2 100644 --- a/src/app/utils.rs +++ b/src/app/utils.rs @@ -3,7 +3,7 @@ use dioxus::prelude::*; use crate::{ app::models::Message, - backend::models::{Puzzle, PuzzleSolutions}, + backend::models::{Puzzle, PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, }; pub fn parse_puzzle_csv( @@ -82,3 +82,11 @@ pub fn popup_normal( ) { signal_message.set(Some((Message::MsgNorm, text.to_string()))); } + +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() +} From f6116b05fc1305a6223a49a522ce734d2d08e70c Mon Sep 17 00:00:00 2001 From: csboo Date: Mon, 15 Dec 2025 02:12:27 +0100 Subject: [PATCH 57/70] feat(client): support wipe logout + alert popups + sparate TeamSection --- src/app.rs | 46 ++------ src/app/actions.rs | 7 +- src/components/alert_dialog/component.rs | 75 +++++++++++++ src/components/alert_dialog/mod.rs | 2 + src/components/alert_dialog/style.css | 135 +++++++++++++++++++++++ src/components/mod.rs | 2 + src/components/team_section.rs | 79 +++++++++++++ 7 files changed, 311 insertions(+), 35 deletions(-) create mode 100644 src/components/alert_dialog/component.rs create mode 100644 src/components/alert_dialog/mod.rs create mode 100644 src/components/alert_dialog/style.css create mode 100644 src/components/team_section.rs diff --git a/src/app.rs b/src/app.rs index 9f99730..7f82cb4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,6 @@ use dioxus::prelude::*; pub mod actions; mod hooks; mod models; -mod utils; const BUTTON: &str = "w-30 px-3 py-2 rounded-lg border border-red-900 bg-red-400 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition"; pub mod utils; @@ -14,11 +13,10 @@ pub mod utils; pub use crate::app::models::{AuthState, Message}; use crate::{ - app::actions::handle_logout, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, components::{ input_section::InputSection, message_popup::MessagePopup, score_table::ScoreTable, - team_status::TeamStatus, + team_section::TeamSection, }, }; @@ -42,6 +40,9 @@ pub fn App() -> Element { 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 @@ -50,25 +51,6 @@ pub fn App() -> Element { hooks::load_title(title, message); hooks::subscribe_stream(teams_state, puzzles); - let teams = teams_state.read(); - let ref_puzzles = puzzles.read(); - - let solved = teams - .iter() - .find(|(team, _)| team == &auth_current.username) - .map(|(_, solved)| solved); - - let points: u32 = solved - .as_ref() - .map(|solved| { - ref_puzzles - .iter() - .filter(|(id, _)| solved.contains(id)) - .map(|(_, value)| *value) - .sum() - }) - .unwrap_or(0); - rsx! { document::Link { rel: "icon", href: FAVICON } document::Link { rel: "stylesheet", href: MAIN_CSS } @@ -115,19 +97,15 @@ pub fn App() -> Element { } // div: input-section if auth_current.joined && !auth_current.is_admin{ - div { class: "mt-5", - TeamStatus { - team: auth_current.username.clone(), - points: points, - } - } - div { class: "mt-5", - button { class: "{BUTTON}", - onclick: handle_logout(auth, message), // TODO support wipe logout - cursor: "pointer", - "Kijelentkezés" - } + TeamSection { + auth, + message, + logout_alert, + delete_alert, + teams_state, + puzzles, } + } // Message popup diff --git a/src/app/actions.rs b/src/app/actions.rs index 3777fd2..4660d12 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -180,10 +180,15 @@ pub fn handle_action( pub fn handle_logout( mut auth: Signal, message: Signal>, + superlogout: bool, ) -> impl FnMut(Event) + 'static { + let wipe = match superlogout { + true => Some(true), + false => None, + }; move |_| { spawn(async move { - match crate::backend::endpoints::logout(None::).await { + match crate::backend::endpoints::logout(wipe).await { Ok(_) => { popup_normal(message, format!("Viszlát, {}", auth.read().username)); auth.set(AuthState::default()); 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..9a8ae55 --- /dev/null +++ b/src/components/alert_dialog/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file 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/mod.rs b/src/components/mod.rs index ce44d2a..635cc30 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,5 +1,7 @@ // AUTOGENERTED Components module +pub mod alert_dialog; pub mod input_section; pub mod message_popup; pub mod score_table; +pub mod team_section; pub mod team_status; diff --git a/src/components/team_section.rs b/src/components/team_section.rs new file mode 100644 index 0000000..5d09fac --- /dev/null +++ b/src/components/team_section.rs @@ -0,0 +1,79 @@ +use dioxus::prelude::*; + +use crate::app::{AuthState, Message, actions::handle_logout, utils::get_points_of}; +use crate::backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}; +use crate::components::{alert_dialog::*, team_status::TeamStatus}; + +const BUTTON: &str = "min-w-30 h-[35px] px-3 py-2 rounded-lg border border-red-900 bg-red-400 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition"; + +#[component] +pub fn TeamSection( + auth: Signal, + message: Signal>, + mut logout_alert: Signal, + mut delete_alert: Signal, + mut teams_state: Signal>, + puzzles: Signal>, +) -> Element { + let auth_current = auth.read().clone(); + 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}", + 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, message, false), "Kilépés" } + } + } + } + } + div { class: "mt-2", + button { class: "{BUTTON}", + 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, message, true), "Csapat Törlése" } + } + } + } + } + } +} From 764d44dcb9c9b94cbd58f70285b05a6698f6e4f4 Mon Sep 17 00:00:00 2001 From: csboo Date: Mon, 15 Dec 2025 02:18:49 +0100 Subject: [PATCH 58/70] chore(client): code optimization + separate tailwind consts --- src/app.rs | 6 ++---- src/components/input_section.rs | 5 +---- src/components/mod.rs | 1 + src/components/tailwind_constants.rs | 3 +++ src/components/team_section.rs | 4 +--- tailwind.css | 1 + 6 files changed, 9 insertions(+), 11 deletions(-) create mode 100644 src/components/tailwind_constants.rs diff --git a/src/app.rs b/src/app.rs index 7f82cb4..435e0ed 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,8 +6,6 @@ use dioxus::prelude::*; pub mod actions; mod hooks; mod models; - -const BUTTON: &str = "w-30 px-3 py-2 rounded-lg border border-red-900 bg-red-400 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition"; pub mod utils; pub use crate::app::models::{AuthState, Message}; @@ -21,7 +19,7 @@ use crate::{ }; const FAVICON: Asset = asset!("/assets/favicon.ico"); -const MAIN_CSS: Asset = asset!("/assets/main.css"); +// const MAIN_CSS: Asset = asset!("/assets/main.css"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); #[component] @@ -53,7 +51,7 @@ pub fn App() -> Element { rsx! { document::Link { rel: "icon", href: FAVICON } - document::Link { rel: "stylesheet", href: MAIN_CSS } + // document::Link { rel: "stylesheet", href: MAIN_CSS } document::Link { rel: "stylesheet", href: TAILWIND_CSS } div { class: if *is_fullscreen.read() { "table-only" } else { "normal" }, diff --git a/src/components/input_section.rs b/src/components/input_section.rs index 3b3e1fd..eae3226 100644 --- a/src/components/input_section.rs +++ b/src/components/input_section.rs @@ -3,12 +3,9 @@ use dioxus::prelude::*; use crate::{ app::{AuthState, Message, actions}, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, + components::tailwind_constants::{BUTTON, CSV_INPUT, INPUT}, }; -const BUTTON: &str = "w-30 h-[50px] px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; -const INPUT: &str = "w-50 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"; -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"; - #[component] pub fn InputSection( auth: Signal, diff --git a/src/components/mod.rs b/src/components/mod.rs index 635cc30..67f5106 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -3,5 +3,6 @@ pub mod alert_dialog; pub mod input_section; pub mod message_popup; pub mod score_table; +pub mod tailwind_constants; pub mod team_section; pub mod team_status; diff --git a/src/components/tailwind_constants.rs b/src/components/tailwind_constants.rs new file mode 100644 index 0000000..c9475fe --- /dev/null +++ b/src/components/tailwind_constants.rs @@ -0,0 +1,3 @@ +pub const BUTTON: &str = "w-30 h-[50px] px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; +pub const INPUT: &str = "w-50 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"; diff --git a/src/components/team_section.rs b/src/components/team_section.rs index 5d09fac..df6ff45 100644 --- a/src/components/team_section.rs +++ b/src/components/team_section.rs @@ -2,9 +2,7 @@ use dioxus::prelude::*; use crate::app::{AuthState, Message, actions::handle_logout, utils::get_points_of}; use crate::backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}; -use crate::components::{alert_dialog::*, team_status::TeamStatus}; - -const BUTTON: &str = "min-w-30 h-[35px] px-3 py-2 rounded-lg border border-red-900 bg-red-400 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition"; +use crate::components::{alert_dialog::*, tailwind_constants::BUTTON, team_status::TeamStatus}; #[component] pub fn TeamSection( diff --git a/tailwind.css b/tailwind.css index 3cd4b02..b72a662 100644 --- a/tailwind.css +++ b/tailwind.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "./assets/main.css"; @theme { --text-lg: 3rem; From b86dbeb60cc4043515f39aa1e64ba3efc20c4d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jeromos=20Kov=C3=A1cs?= Date: Mon, 15 Dec 2025 15:56:42 +0100 Subject: [PATCH 59/70] chore(assets): add proper favicon, thanks to @xoudoesthings, delete dx header --- assets/favicon.ico | Bin 132770 -> 128860 bytes assets/favicon.svg | 108 +++++++++++++++++++++++++++++++++++++++++++++ assets/header.svg | 20 --------- 3 files changed, 108 insertions(+), 20 deletions(-) create mode 100644 assets/favicon.svg delete mode 100644 assets/header.svg diff --git a/assets/favicon.ico b/assets/favicon.ico index eed0c09735ab94e724c486a053c367cf7ee3d694..d8b92f63e91ec25b7b3f4512505c39f18764912c 100644 GIT binary patch literal 128860 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 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 From b9222a18350563e6edab64a826ccca591a767a7a Mon Sep 17 00:00:00 2001 From: csboo Date: Mon, 15 Dec 2025 15:54:06 +0100 Subject: [PATCH 60/70] misc(client): totally unneeded css flash on buttons --- src/components/input_section.rs | 8 ++++---- src/components/tailwind_constants.rs | 14 ++++++++++++-- src/components/team_section.rs | 9 ++++++--- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/components/input_section.rs b/src/components/input_section.rs index eae3226..b026427 100644 --- a/src/components/input_section.rs +++ b/src/components/input_section.rs @@ -3,7 +3,7 @@ use dioxus::prelude::*; use crate::{ app::{AuthState, Message, actions}, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, - components::tailwind_constants::{BUTTON, CSV_INPUT, INPUT}, + components::tailwind_constants::{BUTTON, CSV_INPUT, FLASH, INPUT}, }; #[component] @@ -51,7 +51,7 @@ pub fn InputSection( } } - button { class: BUTTON, cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Belépés" } + button { class: "{BUTTON} {FLASH}", cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Belépés" } } else { // Submit form if !auth_current.is_admin { @@ -115,9 +115,9 @@ pub fn InputSection( onchange: actions::handle_csv(parsed_puzzles, message), } - button { class: BUTTON, cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Beállítás" } + button { class: "{BUTTON} {FLASH}", cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Beállítás" } } else { - button { class: BUTTON, cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Küldés" } + button { class: "{BUTTON} {FLASH}", cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Küldés" } } }) } diff --git a/src/components/tailwind_constants.rs b/src/components/tailwind_constants.rs index c9475fe..c27f1a4 100644 --- a/src/components/tailwind_constants.rs +++ b/src/components/tailwind_constants.rs @@ -1,3 +1,13 @@ -pub const BUTTON: &str = "w-30 h-[50px] px-3 py-2 rounded-lg border border-(--dark2) bg-(--middle) text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"; -pub const INPUT: &str = "w-50 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 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 index df6ff45..f4d6161 100644 --- a/src/components/team_section.rs +++ b/src/components/team_section.rs @@ -2,7 +2,10 @@ use dioxus::prelude::*; use crate::app::{AuthState, Message, actions::handle_logout, utils::get_points_of}; use crate::backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}; -use crate::components::{alert_dialog::*, tailwind_constants::BUTTON, team_status::TeamStatus}; +use crate::components::{ + alert_dialog::*, tailwind_constants::BUTTON_RED, tailwind_constants::FLASH, + team_status::TeamStatus, +}; #[component] pub fn TeamSection( @@ -29,7 +32,7 @@ pub fn TeamSection( } } div { class: "mt-10", - button { class: "{BUTTON}", + button { class: "{BUTTON_RED} {FLASH}", onclick: move |_| logout_alert.set(true), cursor: "pointer", "Kijelentkezés" @@ -51,7 +54,7 @@ pub fn TeamSection( } } div { class: "mt-2", - button { class: "{BUTTON}", + button { class: "{BUTTON_RED} {FLASH}", onclick: move |_| delete_alert.set(true), cursor: "pointer", "Csapat törlése" From 8c4ceceb2178a26ddbfbba90005a5b8306868c8b Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 16 Dec 2025 11:45:05 +0100 Subject: [PATCH 61/70] fix(client): validate values before sending --- src/app/actions.rs | 31 ++++++++++++++++++++++++------- src/app/models.rs | 32 ++++++++++++++++++++++++++++++++ src/app/utils.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index 4660d12..4a25251 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -3,7 +3,10 @@ use dioxus::{prelude::*, signals::Signal}; use crate::{ app::{ models::{AuthState, Message}, - utils::{parse_puzzle_csv, popup_error, popup_normal}, + utils::{ + parse_puzzle_csv, popup_error, popup_normal, validate_puzzle_id, + validate_puzzle_solution, validate_puzzle_value, + }, }, backend::models::{Puzzle, PuzzleSolutions}, }; @@ -17,8 +20,7 @@ fn check_admin_username(username: String) -> bool { pub async fn handle_join(mut auth: Signal, message: Signal>) { let u = auth.read().username.clone(); - if u.trim().is_empty() { - popup_error(message, "A csapatnév mező nem lehet üres"); + if !auth.read().validate_username(message) { return; } @@ -27,8 +29,7 @@ pub async fn handle_join(mut auth: Signal, message: Signal, message: Signal { popup_normal(message, format!("Üdv, {}", uname)); - auth.write().joined = true; + auth.write().joined = true; // TODO auth.reset(_somefield) auth.write().password = String::new(); auth.write().show_password_prompt = false; } @@ -59,6 +60,13 @@ pub async fn handle_user_submit( ) { let puzzle_current = puzzle_id.read().clone(); let solution_current = puzzle_solution.read().clone(); + if !validate_puzzle_id(&puzzle_current, message) { + return; + } + if !validate_puzzle_solution(&solution_current, message) { + return; + } + match crate::backend::endpoints::submit_solution(puzzle_current, solution_current).await { Ok(msg) => { popup_normal(message, msg); @@ -79,9 +87,18 @@ pub async fn handle_admin_submit( password_current: String, message: Signal>, ) { - // Submit solution - call backend function directly match crate::backend::endpoints::set_solution( if parsed_puzzles.read().is_empty() { + if !validate_puzzle_id(&puzzle_id.read().clone(), message) { + return; + } + if !validate_puzzle_solution(&puzzle_solution.read().clone(), message) { + return; + } + if !validate_puzzle_value(&puzzle_value.read().clone(), message) { + return; + } + debug!("parsed puzzles is empty, trying from manual values"); let Ok(value_current) = puzzle_value.read().parse() else { popup_error(message, "Az érték csak szám lehet"); diff --git a/src/app/models.rs b/src/app/models.rs index 5fb4ad4..eed2421 100644 --- a/src/app/models.rs +++ b/src/app/models.rs @@ -1,3 +1,7 @@ +use dioxus::signals::Signal; + +use crate::app::utils::popup_error; + #[derive(Default, Clone, PartialEq)] pub struct AuthState { pub(crate) username: String, @@ -7,6 +11,34 @@ pub struct AuthState { pub(crate) show_password_prompt: bool, } +impl AuthState { + pub fn validate_username(&self, message: Signal>) -> bool { + if self.username.is_empty() { + popup_error(message, "A csapatnév nem lehet üres"); + return false; + } + true + } + pub fn validate_password(&self, message: Signal>) -> bool { + if self.is_admin && self.password.is_empty() { + popup_error(message, "A jelszó nem lehet üres"); + return false; + } + true + } + + pub fn validate(&self, message: Signal>) -> bool { + if !self.validate_username(message) { + return false; + }; + if !self.validate_password(message) { + return false; + }; + + true + } +} + #[derive(Clone, PartialEq)] pub enum Message { MsgNorm, diff --git a/src/app/utils.rs b/src/app/utils.rs index 4b56fb2..58bea4c 100644 --- a/src/app/utils.rs +++ b/src/app/utils.rs @@ -90,3 +90,37 @@ pub fn get_points_of(team: &(String, SolvedPuzzles), puzzles: Vec<(PuzzleId, Puz .map(|(_, value)| *value) .sum() } + +pub fn validate_puzzle_id(puzzle_id: &str, message: Signal>) -> bool { + match !puzzle_id.is_empty() { + true => true, + false => { + popup_error(message, "a feladat nem lehet üres"); + false + } + } +} +pub fn validate_puzzle_solution( + puzzle_solution: &str, + message: Signal>, +) -> bool { + match !puzzle_solution.is_empty() { + true => true, + false => { + popup_error(message, "a megoldás nem lehet üres"); + false + } + } +} +pub fn validate_puzzle_value( + puzzle_value: &str, + message: Signal>, +) -> bool { + match !puzzle_value.is_empty() { + true => true, + false => { + popup_error(message, "az érték nem lehet üres"); + false + } + } +} From 1af562a05e854a27ddb70f5a989c1cbdf6ddb191 Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 16 Dec 2025 12:57:54 +0100 Subject: [PATCH 62/70] feat(client): set admin password when admin join --- src/app/actions.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/actions.rs b/src/app/actions.rs index 4a25251..b773894 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -32,6 +32,8 @@ pub async fn handle_join(mut auth: Signal, message: Signal Date: Tue, 16 Dec 2025 13:15:44 +0100 Subject: [PATCH 63/70] misc(client): ui improvment --- assets/main.css | 4 ---- src/components/score_table.rs | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/assets/main.css b/assets/main.css index 54a1a4d..5a7a31b 100644 --- a/assets/main.css +++ b/assets/main.css @@ -83,10 +83,6 @@ tr { color: var(--light); } -th { - height: 40px; -} - td { min-width: 10rem; height: 50px; diff --git a/src/components/score_table.rs b/src/components/score_table.rs index ebc7d39..8d7145a 100644 --- a/src/components/score_table.rs +++ b/src/components/score_table.rs @@ -19,9 +19,9 @@ pub fn ScoreTable( thead { tr { if !puzzles.read().is_empty() || !teams_state.read().is_empty() { - th { class: "text-left pl-2", "." } + th { class: "text-left h-[70px] pl-2", "." } for (id, value) in puzzles.read().iter() { - th { + th { class: "h-[70px]", span { class: "text-md", "{id}" } From b3b2f1c42ca7ff3d29bd532e2e1d1c68fa4681a2 Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 16 Dec 2025 23:51:09 +0100 Subject: [PATCH 64/70] misc(client): small ui changes --- src/app/hooks.rs | 2 +- src/components/input_section.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/hooks.rs b/src/app/hooks.rs index ab392b7..55c2671 100644 --- a/src/app/hooks.rs +++ b/src/app/hooks.rs @@ -37,7 +37,7 @@ pub fn subscribe_stream( let mut stream = crate::backend::endpoints::state_stream().await?; 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(); + 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| { diff --git a/src/components/input_section.rs b/src/components/input_section.rs index b026427..a2ed3e2 100644 --- a/src/components/input_section.rs +++ b/src/components/input_section.rs @@ -69,7 +69,7 @@ pub fn InputSection( option { cursor: "pointer", value: "{id}", - "{id}. feladat" + "{id}" } } } From 622614f97f32269b433a1e88085b69b9d3f59175 Mon Sep 17 00:00:00 2001 From: csboo Date: Tue, 16 Dec 2025 23:51:54 +0100 Subject: [PATCH 65/70] fix(client): respect server admin password errors --- src/app/actions.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index b773894..e6196e6 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -29,12 +29,19 @@ pub async fn handle_join(mut auth: Signal, message: Signal { + auth.write().joined = true; + popup_normal(message, msg); + } + Err(e) => popup_error( + message, + format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), + ), + } } - let _ignored_result = - crate::backend::endpoints::set_admin_password(auth.read().password.clone()).await; - auth.write().joined = true; return; }; From bd2f630d1c5ce49c1625af931f407c343b13bd01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jeromos=20Kov=C3=A1cs?= Date: Wed, 17 Dec 2025 00:09:05 +0100 Subject: [PATCH 66/70] chore(make): delete cache, don't bloat, ulimit won't work inside the makefile, don't forget though --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f6dfcaf..ee1ad89 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ build: dx bundle ${dx-args} 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 @@ -20,7 +21,7 @@ web-bundle: unzip -l web-apollo.zip server-build: - ulimit -n 1024 # needed on macos for sure + @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 From 5e7993efd85ad680a711385d20b203dab16230fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jeromos=20Kov=C3=A1cs?= Date: Wed, 17 Dec 2025 23:07:45 +0100 Subject: [PATCH 67/70] chore: add build script and so banner --- Cargo.toml | 5 +++++ build.rs | 38 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 2 ++ 3 files changed, 45 insertions(+) create mode 100644 build.rs diff --git a/Cargo.toml b/Cargo.toml index 5bbd7f4..7459566 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ name = "apollo" version = "0.1.0" authors = ["csboo ", "Jeromos Kovacs "] +description = "a simple hackathon progress tracking app" +repository = "https://github.com/csboo/apollo" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -27,3 +29,6 @@ desktop = ["dioxus/desktop", "dep:tokio"] server = ["dioxus/server", "dep:tokio", "dep:secrecy", "dep:uuid"] # save server state server_state_save = ["server", "dep:ciborium", "dep:chacha20poly1305", "dep:rust-argon2"] + +[build-dependencies] +humantime = "2.3.0" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..e8c953f --- /dev/null +++ b/build.rs @@ -0,0 +1,38 @@ +//! based on +//! - +//! - +//! - + +use std::env; +use std::process::Command; +use std::time::SystemTime; + +fn main() { + let git_output = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .unwrap(); // NOTE: probably good enough for now + let git_commit_hash = String::from_utf8_lossy(&git_output.stdout); + + let banner_msg = format!( + "{}, {} +version: {} ({} build for {}) +built on {} from commit {} +enabled features: {} +repo: {} +", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_DESCRIPTION"), + env!("CARGO_PKG_VERSION"), + env::var("PROFILE").unwrap(), + env::var("TARGET").unwrap(), + humantime::format_rfc3339_seconds(SystemTime::now()), + git_commit_hash.trim(), + env::var("CARGO_CFG_FEATURE").unwrap(), + env!("CARGO_PKG_REPOSITORY"), + ); + + println!("cargo:rustc-env=BANNER={banner_msg:?}"); + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/refs/heads/"); +} diff --git a/src/main.rs b/src/main.rs index 9ebf007..87dd835 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,8 @@ mod components; fn main() { dioxus::logger::initialize_default(); + eprintln!("{}", env!("BANNER").replace(r"\n", "\n").trim_matches('"')); // had to be escaped + #[cfg(feature = "server")] dioxus::serve(|| async move { use dioxus::cli_config::fullstack_address_or_localhost as dx_server_addr; From 69014e6cb4a9175da9491faa4f195db2e2b7459e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jeromos=20Kov=C3=A1cs?= Date: Fri, 19 Dec 2025 19:18:25 +0100 Subject: [PATCH 68/70] chore: cargo features fix --- Cargo.toml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7459566..6248450 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "apollo" version = "0.1.0" -authors = ["csboo ", "Jeromos Kovacs "] +authors = [ + "csboo ", + "Jeromos Kovacs " +] description = "a simple hackathon progress tracking app" repository = "https://github.com/csboo/apollo" edition = "2024" @@ -26,9 +29,16 @@ default = ["web"] web = ["dioxus/web", "dep:gloo-timers"] desktop = ["dioxus/desktop", "dep:tokio"] # mobile = ["dioxus/mobile"] -server = ["dioxus/server", "dep:tokio", "dep:secrecy", "dep:uuid"] +server = [ + "dioxus/server", + "dep:tokio", + "dep:secrecy", + "dep:uuid", + "dep:chacha20poly1305", + "dep:rust-argon2" +] # save server state -server_state_save = ["server", "dep:ciborium", "dep:chacha20poly1305", "dep:rust-argon2"] +server_state_save = ["server", "dep:ciborium"] [build-dependencies] humantime = "2.3.0" From 7c29c6f69e383fa86090b16f9bec3d4cfc148496 Mon Sep 17 00:00:00 2001 From: csboo Date: Sun, 21 Dec 2025 00:08:27 +0100 Subject: [PATCH 69/70] refactor(client): custom toast -> dioxus_primitives::Toast --- src/app.rs | 118 +++++++++---------- src/app/actions.rs | 59 +++++----- src/app/hooks.rs | 30 +---- src/app/models.rs | 22 ++-- src/app/utils.rs | 77 ++++++------- src/components/input_section.rs | 13 ++- src/components/message_popup.rs | 21 ---- src/components/mod.rs | 1 + src/components/team_section.rs | 9 +- src/components/toast/component.rs | 15 +++ src/components/toast/mod.rs | 2 + src/components/toast/style.css | 185 ++++++++++++++++++++++++++++++ 12 files changed, 351 insertions(+), 201 deletions(-) delete mode 100644 src/components/message_popup.rs create mode 100644 src/components/toast/component.rs create mode 100644 src/components/toast/mod.rs create mode 100644 src/components/toast/style.css diff --git a/src/app.rs b/src/app.rs index 435e0ed..361be9e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,19 +8,20 @@ mod hooks; mod models; pub mod utils; -pub use crate::app::models::{AuthState, Message}; +pub use crate::app::models::AuthState; use crate::{ backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, components::{ - input_section::InputSection, message_popup::MessagePopup, score_table::ScoreTable, - team_section::TeamSection, + 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 TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); +const DX_CSS: Asset = asset!("/assets/dx-components-theme.css"); #[component] pub fn App() -> Element { @@ -34,7 +35,6 @@ pub fn App() -> Element { let auth_current = auth.read(); let teams_state = use_signal(Vec::<(String, SolvedPuzzles)>::new); let puzzles = use_signal(Vec::<(PuzzleId, PuzzleValue)>::new); - let message = use_signal(|| None::<(Message, String)>); let title = use_signal(|| None::); let is_fullscreen = use_signal(|| false); let parsed_puzzles = use_signal(PuzzleSolutions::new); @@ -44,77 +44,69 @@ pub fn App() -> Element { trace!("variables inited"); // side effect handlers - hooks::auto_hide_message(message); - hooks::check_auth(auth, message); - hooks::load_title(title, message); + 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 } - - 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" + 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}", } - } - } - - } // 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, - message, - puzzle_id, - puzzle_value, - puzzle_solution, - parsed_puzzles, - teams_state, - puzzles, + } else { + div { class: "loading", + h1 { class: "font-bold text-[clamp(1rem,4vw,2.5rem)]", + "Várakozás az Apollo kiszolgálóra" + } } - } // div: input-section + } - if auth_current.joined && !auth_current.is_admin{ - TeamSection { - auth, - message, - logout_alert, - delete_alert, - teams_state, - puzzles, - } + } // 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, + } - // Message popup - if let Some(m) = &*message.read() { - MessagePopup { - level: m.0.clone(), - text: m.1.clone(), } - } // end message - } - } // div: other-container - } // end main div + } + } // div: other-container + } // end main div + } } } diff --git a/src/app/actions.rs b/src/app/actions.rs index e6196e6..7e7936c 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1,8 +1,9 @@ use dioxus::{prelude::*, signals::Signal}; +use dioxus_primitives::toast::Toasts; use crate::{ app::{ - models::{AuthState, Message}, + models::AuthState, utils::{ parse_puzzle_csv, popup_error, popup_normal, validate_puzzle_id, validate_puzzle_solution, validate_puzzle_value, @@ -18,9 +19,9 @@ fn check_admin_username(username: String) -> bool { username == admin_username } -pub async fn handle_join(mut auth: Signal, message: Signal>) { +pub async fn handle_join(mut auth: Signal, toast_api: Toasts) { let u = auth.read().username.clone(); - if !auth.read().validate_username(message) { + if !auth.read().validate_username(toast_api) { return; } @@ -29,15 +30,15 @@ pub async fn handle_join(mut auth: Signal, message: Signal { auth.write().joined = true; - popup_normal(message, msg); + popup_normal(toast_api, msg); } Err(e) => popup_error( - message, + toast_api, format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), ), } @@ -48,14 +49,14 @@ pub async fn handle_join(mut auth: Signal, message: Signal { - popup_normal(message, format!("Üdv, {}", 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( - message, + toast_api, format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), ); } @@ -65,25 +66,25 @@ pub async fn handle_join(mut auth: Signal, message: Signal, mut puzzle_solution: Signal, - message: Signal>, + toast_api: Toasts, ) { let puzzle_current = puzzle_id.read().clone(); let solution_current = puzzle_solution.read().clone(); - if !validate_puzzle_id(&puzzle_current, message) { + if !validate_puzzle_id(&puzzle_current, toast_api) { return; } - if !validate_puzzle_solution(&solution_current, message) { + if !validate_puzzle_solution(&solution_current, toast_api) { return; } match crate::backend::endpoints::submit_solution(puzzle_current, solution_current).await { Ok(msg) => { - popup_normal(message, msg); + popup_normal(toast_api, msg); puzzle_id.set(String::new()); puzzle_solution.set(String::new()); } Err(e) => { - popup_error(message, format!("Hiba: {}", e)); + popup_error(toast_api, format!("Hiba: {}", e)); } } } @@ -94,23 +95,23 @@ pub async fn handle_admin_submit( mut puzzle_solution: Signal, parsed_puzzles: Signal, password_current: String, - message: Signal>, + toast_api: Toasts, ) { match crate::backend::endpoints::set_solution( if parsed_puzzles.read().is_empty() { - if !validate_puzzle_id(&puzzle_id.read().clone(), message) { + if !validate_puzzle_id(&puzzle_id.read().clone(), toast_api) { return; } - if !validate_puzzle_solution(&puzzle_solution.read().clone(), message) { + if !validate_puzzle_solution(&puzzle_solution.read().clone(), toast_api) { return; } - if !validate_puzzle_value(&puzzle_value.read().clone(), message) { + 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(message, "Az érték csak szám lehet"); + popup_error(toast_api, "Az érték csak szám lehet"); return; }; PuzzleSolutions::from([( @@ -128,7 +129,7 @@ pub async fn handle_admin_submit( .await { Ok(msg) => { - popup_normal(message, msg); + popup_normal(toast_api, msg); puzzle_id.set(String::new()); puzzle_solution.set(String::new()); puzzle_value.set(String::new()); @@ -136,7 +137,7 @@ pub async fn handle_admin_submit( } Err(e) => { popup_error( - message, + toast_api, format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), ); } @@ -145,7 +146,7 @@ pub async fn handle_admin_submit( pub fn handle_csv( mut parsed_puzzles: Signal, - message: Signal>, + toast_api: Toasts, ) -> impl FnMut(Event) + 'static { move |form_data| { spawn(async move { @@ -154,7 +155,7 @@ pub fn handle_csv( warn!("couldn't parse text from selected file"); return; }; - parsed_puzzles.set(parse_puzzle_csv(&text, message)); + parsed_puzzles.set(parse_puzzle_csv(&text, toast_api)); debug!("set puzzles from csv"); } else { warn!("couldn't read selected file"); @@ -175,7 +176,7 @@ pub fn toggle_fullscreen( pub fn handle_action( auth: Signal, - message: Signal>, + toast_api: Toasts, puzzle_id: Signal, puzzle_value: Signal, puzzle_solution: Signal, @@ -185,7 +186,7 @@ pub fn handle_action( spawn(async move { trace!("action handler called"); if !auth.read().joined { - self::handle_join(auth, message).await; + self::handle_join(auth, toast_api).await; } else if auth.read().is_admin { self::handle_admin_submit( puzzle_id, @@ -193,11 +194,11 @@ pub fn handle_action( puzzle_solution, parsed_puzzles, auth.read().password.clone(), - message, + toast_api, ) .await; } else { - self::handle_user_submit(puzzle_id, puzzle_solution, message).await; + self::handle_user_submit(puzzle_id, puzzle_solution, toast_api).await; } }); } @@ -205,7 +206,7 @@ pub fn handle_action( pub fn handle_logout( mut auth: Signal, - message: Signal>, + toast_api: Toasts, superlogout: bool, ) -> impl FnMut(Event) + 'static { let wipe = match superlogout { @@ -216,12 +217,12 @@ pub fn handle_logout( spawn(async move { match crate::backend::endpoints::logout(wipe).await { Ok(_) => { - popup_normal(message, format!("Viszlát, {}", auth.read().username)); + popup_normal(toast_api, format!("Viszlát, {}", auth.read().username)); auth.set(AuthState::default()); } Err(e) => { popup_error( - message, + toast_api, format!("Hiba: {}", e.message.unwrap_or("ismeretlen hiba".into())), ); } diff --git a/src/app/hooks.rs b/src/app/hooks.rs index 55c2671..b0c52b6 100644 --- a/src/app/hooks.rs +++ b/src/app/hooks.rs @@ -1,30 +1,27 @@ use dioxus::prelude::*; use crate::{ - app::{ - AuthState, Message, - utils::{get_points_of, popup_error, popup_normal}, - }, + app::{AuthState, utils::get_points_of}, backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, }; -pub fn load_title(mut title: Signal>, message: Signal>) { +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))) + // .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, message: Signal>) { +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}")); + // popup_normal(message, format!("Üdv újra, {name}")); //TODO WARN } }); } @@ -34,7 +31,7 @@ pub fn subscribe_stream( mut puzzles: Signal>, ) { use_future(move || async move { - let mut stream = crate::backend::endpoints::state_stream().await?; + 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))); @@ -52,18 +49,3 @@ pub fn subscribe_stream( dioxus::Ok(()) }); } - -pub fn auto_hide_message(mut message: Signal>) { - use_effect(move || { - if message.read().is_some() { - spawn(async move { - #[cfg(feature = "web")] - gloo_timers::future::sleep(core::time::Duration::from_secs(5)).await; - #[cfg(feature = "desktop")] - tokio::time::sleep(core::time::Duration::from_secs(5)).await; - - message.set(None); - }); - } - }); -} diff --git a/src/app/models.rs b/src/app/models.rs index eed2421..e607113 100644 --- a/src/app/models.rs +++ b/src/app/models.rs @@ -1,4 +1,4 @@ -use dioxus::signals::Signal; +use dioxus_primitives::toast::Toasts; use crate::app::utils::popup_error; @@ -12,35 +12,29 @@ pub struct AuthState { } impl AuthState { - pub fn validate_username(&self, message: Signal>) -> bool { + pub fn validate_username(&self, toast_api: Toasts) -> bool { if self.username.is_empty() { - popup_error(message, "A csapatnév nem lehet üres"); + popup_error(toast_api, "A csapatnév nem lehet üres"); return false; } true } - pub fn validate_password(&self, message: Signal>) -> bool { + pub fn validate_password(&self, toast_api: Toasts) -> bool { if self.is_admin && self.password.is_empty() { - popup_error(message, "A jelszó nem lehet üres"); + popup_error(toast_api, "A jelszó nem lehet üres"); return false; } true } - pub fn validate(&self, message: Signal>) -> bool { - if !self.validate_username(message) { + pub fn validate(&self, toast_api: Toasts) -> bool { + if !self.validate_username(toast_api) { return false; }; - if !self.validate_password(message) { + if !self.validate_password(toast_api) { return false; }; true } } - -#[derive(Clone, PartialEq)] -pub enum Message { - MsgNorm, - MsgErr, -} diff --git a/src/app/utils.rs b/src/app/utils.rs index 58bea4c..0293910 100644 --- a/src/app/utils.rs +++ b/src/app/utils.rs @@ -1,15 +1,12 @@ +use std::time::Duration; + use csv::ReaderBuilder; use dioxus::prelude::*; +use dioxus_primitives::toast::{ToastOptions, Toasts}; -use crate::{ - app::models::Message, - backend::models::{Puzzle, PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, -}; +use crate::backend::models::{Puzzle, PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}; -pub fn parse_puzzle_csv( - csv_text: &str, - message: Signal>, -) -> PuzzleSolutions { +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()); @@ -20,32 +17,32 @@ pub fn parse_puzzle_csv( for result in rdr.records() { let record = match result { Ok(r) => r, - Err(e) => { - warn!("skipping invalid CSV row: {}", e); + 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 + // 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); + // 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); + // 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 - ); + // warn!( + // "value of field 'value' is not a number in CSV row: {:?}", + // &record + // ); volte = true; continue; }; @@ -61,7 +58,7 @@ pub fn parse_puzzle_csv( if volte { popup_error( - message, + toast_api, "néhány sort nem sikerült betölteni, nézd meg a konzolt", ); } @@ -69,18 +66,24 @@ pub fn parse_puzzle_csv( puzzles } -pub fn popup_error( - mut signal_message: Signal>, - text: impl std::fmt::Display, -) { - signal_message.set(Some((Message::MsgErr, text.to_string()))); +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( - mut signal_message: Signal>, - text: impl std::fmt::Display, -) { - signal_message.set(Some((Message::MsgNorm, text.to_string()))); +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 { @@ -91,35 +94,29 @@ pub fn get_points_of(team: &(String, SolvedPuzzles), puzzles: Vec<(PuzzleId, Puz .sum() } -pub fn validate_puzzle_id(puzzle_id: &str, message: Signal>) -> bool { +pub fn validate_puzzle_id(puzzle_id: &str, toast_api: Toasts) -> bool { match !puzzle_id.is_empty() { true => true, false => { - popup_error(message, "a feladat nem lehet üres"); + popup_error(toast_api, "a feladat nem lehet üres"); false } } } -pub fn validate_puzzle_solution( - puzzle_solution: &str, - message: Signal>, -) -> bool { +pub fn validate_puzzle_solution(puzzle_solution: &str, toast_api: Toasts) -> bool { match !puzzle_solution.is_empty() { true => true, false => { - popup_error(message, "a megoldás nem lehet üres"); + popup_error(toast_api, "a megoldás nem lehet üres"); false } } } -pub fn validate_puzzle_value( - puzzle_value: &str, - message: Signal>, -) -> bool { +pub fn validate_puzzle_value(puzzle_value: &str, toast_api: Toasts) -> bool { match !puzzle_value.is_empty() { true => true, false => { - popup_error(message, "az érték nem lehet üres"); + popup_error(toast_api, "az érték nem lehet üres"); false } } diff --git a/src/components/input_section.rs b/src/components/input_section.rs index a2ed3e2..2b1b51a 100644 --- a/src/components/input_section.rs +++ b/src/components/input_section.rs @@ -1,7 +1,8 @@ use dioxus::prelude::*; +use dioxus_primitives::toast::use_toast; use crate::{ - app::{AuthState, Message, actions}, + app::{AuthState, actions}, backend::models::{PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}, components::tailwind_constants::{BUTTON, CSV_INPUT, FLASH, INPUT}, }; @@ -9,7 +10,6 @@ use crate::{ #[component] pub fn InputSection( auth: Signal, - message: Signal>, puzzle_id: Signal, puzzle_value: Signal, puzzle_solution: Signal, @@ -20,6 +20,7 @@ pub fn InputSection( 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() @@ -51,7 +52,7 @@ pub fn InputSection( } } - button { class: "{BUTTON} {FLASH}", cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Belépés" } + 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 { @@ -112,12 +113,12 @@ pub fn InputSection( r#type: "file", r#accept: ".csv", cursor: "pointer", - onchange: actions::handle_csv(parsed_puzzles, message), + onchange: actions::handle_csv(parsed_puzzles, toast_api), } - button { class: "{BUTTON} {FLASH}", cursor: "pointer", onclick: actions::handle_action(auth, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Beállítás" } + 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, message, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles), "Küldés" } + 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/message_popup.rs b/src/components/message_popup.rs deleted file mode 100644 index 32a7958..0000000 --- a/src/components/message_popup.rs +++ /dev/null @@ -1,21 +0,0 @@ -use dioxus::prelude::*; - -use crate::app::Message; - -#[component] -pub fn MessagePopup(text: String, level: Message) -> Element { - rsx! { - div { - class: "popup", - id: match level { - Message::MsgNorm => { - "msgnorm" - }, - Message::MsgErr => { - "msgerr" - }, - }, - "{text}" - } - } -} diff --git a/src/components/mod.rs b/src/components/mod.rs index 67f5106..ef590e7 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -6,3 +6,4 @@ pub mod score_table; pub mod tailwind_constants; pub mod team_section; pub mod team_status; +pub mod toast; diff --git a/src/components/team_section.rs b/src/components/team_section.rs index f4d6161..656ad1e 100644 --- a/src/components/team_section.rs +++ b/src/components/team_section.rs @@ -1,6 +1,7 @@ use dioxus::prelude::*; +use dioxus_primitives::toast::use_toast; -use crate::app::{AuthState, Message, actions::handle_logout, utils::get_points_of}; +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, @@ -10,13 +11,13 @@ use crate::components::{ #[component] pub fn TeamSection( auth: Signal, - message: 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() @@ -48,7 +49,7 @@ pub fn TeamSection( } AlertDialogActions { AlertDialogCancel { "Mégsem" } - AlertDialogAction { on_click: handle_logout(auth, message, false), "Kilépés" } + AlertDialogAction { on_click: handle_logout(auth, toast_api, false), "Kilépés" } } } } @@ -71,7 +72,7 @@ pub fn TeamSection( } AlertDialogActions { AlertDialogCancel { "Mégsem" } - AlertDialogAction { on_click: handle_logout(auth, message, true), "Csapat Törlése" } + AlertDialogAction { on_click: handle_logout(auth, toast_api, true), "Csapat Törlése" } } } } 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..9a8ae55 --- /dev/null +++ b/src/components/toast/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file 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); +} From d9b33c0b275d590eba25b2e6dd0246ba9620dd1b Mon Sep 17 00:00:00 2001 From: csboo Date: Sun, 21 Dec 2025 00:09:50 +0100 Subject: [PATCH 70/70] misc(client): css adjustments --- assets/dx-components-theme.css | 6 +++--- assets/main.css | 4 ++-- src/app/utils.rs | 1 - src/components/alert_dialog/mod.rs | 2 +- src/components/mod.rs | 1 - src/components/toast/mod.rs | 2 +- tailwind.css | 4 ++-- 7 files changed, 9 insertions(+), 11 deletions(-) diff --git a/assets/dx-components-theme.css b/assets/dx-components-theme.css index 6d51a26..a0ab6cc 100644 --- a/assets/dx-components-theme.css +++ b/assets/dx-components-theme.css @@ -5,9 +5,9 @@ body { padding: 0; - margin: 0; - background-color: var(--primary-color); - color: var(--secondary-color-4); + margin: 20px; + background-color: var(--bg); + color: var(--light1); font-family: Inter, sans-serif; font-optical-sizing: auto; font-style: normal; diff --git a/assets/main.css b/assets/main.css index 5a7a31b..5333dc2 100644 --- a/assets/main.css +++ b/assets/main.css @@ -68,7 +68,7 @@ body { background-color: var(--bg); - color: var(--light); + color: var(--light1); font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; margin: 20px; } @@ -80,7 +80,7 @@ td, tr { border: solid 2px var(--middle); border-collapse: collapse; - color: var(--light); + color: var(--light1); } td { diff --git a/src/app/utils.rs b/src/app/utils.rs index 0293910..48525ad 100644 --- a/src/app/utils.rs +++ b/src/app/utils.rs @@ -1,7 +1,6 @@ use std::time::Duration; use csv::ReaderBuilder; -use dioxus::prelude::*; use dioxus_primitives::toast::{ToastOptions, Toasts}; use crate::backend::models::{Puzzle, PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}; diff --git a/src/components/alert_dialog/mod.rs b/src/components/alert_dialog/mod.rs index 9a8ae55..2590c01 100644 --- a/src/components/alert_dialog/mod.rs +++ b/src/components/alert_dialog/mod.rs @@ -1,2 +1,2 @@ mod component; -pub use component::*; \ No newline at end of file +pub use component::*; diff --git a/src/components/mod.rs b/src/components/mod.rs index ef590e7..dd54395 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,7 +1,6 @@ // AUTOGENERTED Components module pub mod alert_dialog; pub mod input_section; -pub mod message_popup; pub mod score_table; pub mod tailwind_constants; pub mod team_section; diff --git a/src/components/toast/mod.rs b/src/components/toast/mod.rs index 9a8ae55..2590c01 100644 --- a/src/components/toast/mod.rs +++ b/src/components/toast/mod.rs @@ -1,2 +1,2 @@ mod component; -pub use component::*; \ No newline at end of file +pub use component::*; diff --git a/tailwind.css b/tailwind.css index b72a662..2ee1825 100644 --- a/tailwind.css +++ b/tailwind.css @@ -4,9 +4,9 @@ @theme { --text-lg: 3rem; --bg: #0f1116; - --dark: #13293d; + --dark1: #13293d; --dark2: #006494; --middle: #247ba0; --light2: #1b98e0; - --light: #e8f1f2; + --light1: #e8f1f2; }