diff --git a/Cargo.toml b/Cargo.toml index ca738a9..37e71e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,18 +12,23 @@ edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +csv = "1.4.0" +dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } +gloo-timers = { version = "0.4.0", features = ["futures"], optional = true } chacha20poly1305 = { version = "0.10.1", optional = true } ciborium = { version = "0.2.2", optional = true } dioxus = { version = "0.7.5", features = ["fullstack"] } # note: argon2 crate worth bearing in mind rust-argon2 = { version = "3.0.0", optional = true } -tokio = { version = "1.51.1", optional = true } -uuid = { version = "1.23.0", features = ["v4", "serde"], optional = true } +tokio = { version = "1.52.1", optional = true } +uuid = { version = "1.23.1", features = ["v4", "serde"], optional = true } zeroize = { version = "1.8.2", optional = true } +dioxus-router = "0.7.4" [features] -default = [] -web = ["dioxus/web"] +default = ["web"] +web = ["dioxus/web", "dep:gloo-timers"] +desktop = ["dioxus/desktop", "dep:tokio"] # desktop = ["dioxus/desktop"] # mobile = ["dioxus/mobile"] server = [ diff --git a/assets/dx-components-theme.css b/assets/dx-components-theme.css new file mode 100644 index 0000000..372dc34 --- /dev/null +++ b/assets/dx-components-theme.css @@ -0,0 +1,99 @@ +/* ============================================ + APOLLO - DIOXUS COMPONENTS THEME + Global styles for styled Dioxus components +============================================ */ + +@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: var(--space-6); + background-color: var(--bg); + color: var(--text-primary); + font-family: Inter, system-ui, -apple-system, sans-serif; + font-optical-sizing: auto; + font-style: normal; + font-weight: 400; + line-height: 1.6; +} + +:root { + /* Primary colors */ + --primary-color: var(--bg); + --primary-color-1: var(--bg); + --primary-color-2: var(--bg); + --primary-color-3: var(--bg-elevated); + --primary-color-4: var(--bg-surface); + --primary-color-5: var(--bg-hover); + --primary-color-6: var(--border-default); + --primary-color-7: var(--border-strong); + + /* Secondary colors */ + --secondary-color: var(--text-primary); + --secondary-color-1: var(--text-primary); + --secondary-color-2: var(--text-primary); + --secondary-color-3: var(--text-secondary); + --secondary-color-4: var(--text-primary); + --secondary-color-5: var(--text-muted); + --secondary-color-6: var(--text-muted); + + /* Highlight colors */ + --focused-border-color: var(--accent-primary); + --primary-success-color: var(--accent-success-subtle); + --secondary-success-color: var(--accent-success); + --primary-warning-color: var(--accent-warning-subtle); + --secondary-warning-color: var(--accent-warning); + --primary-error-color: var(--accent-danger-subtle); + --secondary-error-color: var(--accent-danger); + --contrast-error-color: var(--text-primary); + --primary-info-color: var(--bg-hover); + --secondary-info-color: var(--text-secondary); +} + +/* Scrollbar styling */ +@supports (scrollbar-width: auto) { + * { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; + } + + *:not(:hover) { + scrollbar-color: transparent transparent; + } + + *:hover { + scrollbar-color: var(--border-strong) transparent; + } +} + +@supports selector(::-webkit-scrollbar) { + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: var(--radius-full); + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); + } +} + +/* Focus styles */ +:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +/* Selection styling */ +::selection { + background: var(--accent-primary-subtle); + color: var(--text-primary); +} diff --git a/assets/main.css b/assets/main.css index e69de29..94e793c 100644 --- a/assets/main.css +++ b/assets/main.css @@ -0,0 +1,244 @@ +/* Layout modes */ +.normal .others-container { + display: block; +} + +.table-only .others-container { + display: none !important; +} + +/* Fullscreen table overlay */ +.table-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: var(--space-6); + background: rgba(9, 9, 11, 0.95); + backdrop-filter: blur(12px); + overscroll-behavior: none; + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.table-window { + width: min(95vw, 1400px); + height: min(90vh, 900px); + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-xl); + background: var(--bg-elevated); + box-shadow: var(--shadow-xl); + animation: scaleIn 0.25s ease-out; +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.table-viewport { + width: 100%; + height: 100%; + overflow: auto; + overscroll-behavior: none; +} + +.table-only .score-table { + min-width: 100%; + width: max-content; + margin: 0; +} + +/* Score table shell */ +.score-table-shell { + position: relative; + width: fit-content; +} + +.score-table-shell-preview { + display: inline-block; + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--border-default); + background: var(--bg-elevated); + transition: all 0.2s ease; +} + +.score-table-shell-preview:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-md); +} + +.score-table-preview-overlay { + position: absolute; + inset: 0; + z-index: 4; + display: grid; + place-items: center; + pointer-events: none; + background: linear-gradient( + to bottom, + transparent 40%, + rgba(9, 9, 11, 0.8) 100% + ); + border-radius: var(--radius-lg); +} + +.score-table-preview-overlay-text { + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-full); + background: var(--bg-surface); + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: 500; + border: 1px solid var(--border-default); + box-shadow: var(--shadow-md); +} + +/* Score table sticky headers */ +.score-table { + border-collapse: separate; + border-spacing: 0; +} + +.score-table thead th { + position: sticky; + top: 0; + z-index: 5; + background: var(--bg-elevated); +} + +.score-table thead th:first-child { + left: 0; + z-index: 6; + min-width: 10rem; +} + +.score-table tbody td:first-child { + position: sticky; + left: 0; + z-index: 4; + background: var(--bg-elevated); +} + +/* Loading state */ +.loading { + position: fixed; + inset: 0; + display: grid; + place-items: center; + width: 100vw; + height: 100vh; + background: var(--bg); +} + +/* Toast/popup legacy styles */ +.popup { + width: auto; + height: auto; + position: fixed; + top: var(--space-6); + right: var(--space-6); + background: var(--bg-elevated); + color: var(--text-primary); + padding: var(--space-4) var(--space-5); + font-size: var(--text-sm); + border-radius: var(--radius-lg); + border: 1px solid var(--border-default); + box-shadow: var(--shadow-lg); + animation: + slideIn 0.2s ease-out, + fadeout 0.2s ease-in 2.8s forwards; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-8px) translateX(8px); + } + to { + opacity: 1; + transform: translateY(0) translateX(0); + } +} + +@keyframes fadeout { + to { + opacity: 0; + transform: translateY(-8px); + } +} + +#msgerr.popup { + border-color: var(--accent-danger); +} + +#msgnorm.popup { + border-color: var(--accent-primary); +} + +/* Base body styles */ +body { + background-color: var(--bg); + color: var(--text-primary); + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + margin: 0; + padding: var(--space-6); + min-height: 100vh; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Table styling */ +table { + border-collapse: separate; + border-spacing: 0; +} + +th, td { + border: 1px solid var(--border-default); + color: var(--text-primary); +} + +td { + min-width: 7rem; + height: 3rem; +} + +/* Form container */ +.input-flexy-boxy { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + align-items: flex-end; +} + +/* Smooth scrolling */ +html { + scroll-behavior: smooth; +} + +/* Better focus visibility */ +:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +/* Selection */ +::selection { + background: var(--accent-primary-subtle); + color: var(--text-primary); +} diff --git a/assets/translations.yml b/assets/translations.yml index 479b748..8b092a4 100644 --- a/assets/translations.yml +++ b/assets/translations.yml @@ -11,10 +11,10 @@ terms: # prepare-startup env-var-not-set: en: env var "{$key}" not set, can't proceed - hu: nincs beállítva a "{$key}" környezeti változó, feladjuk + hu: nincs beállítva a(z) "{$key}" környezeti változó, feladjuk empty-env-var: en: env var "{$key}" empty, can't proceed - hu: a "{$key}" környezeti változó üres, feladjuk + hu: a(z) "{$key}" környezeti változó üres, feladjuk state-load-err: en: "couldn't load saved state due tue: {$error}, exiting..." hu: "nem sikerült betölteni az elmentett állapotot: {$error}, feladjuk" diff --git a/src/app.rs b/src/app.rs index fe0eb50..45d6243 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,26 +1,32 @@ +#![deny(clippy::unwrap_used)] +#![forbid(unsafe_code)] + +pub mod admin; +mod components; +pub mod home; + use dioxus::prelude::*; +use crate::routes::ApolloRoutes; +use components::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 { + use dioxus_router::Router; + rsx! { - head { - document::Link { rel: "icon", href: FAVICON } - document::Title { "Apollo" } - // document::Link { rel: "stylesheet", href: MAIN_CSS } - // document::Link { rel: "stylesheet", href: TAILWIND_CSS } - } + document::Link { rel: "icon", href: FAVICON } + document::Link { rel: "stylesheet", href: TAILWIND_CSS } + document::Link { rel: "stylesheet", href: DX_CSS } + document::Link { rel: "stylesheet", href: MAIN_CSS } - body { - h1 { "Apollo" } - "our client is " - a { href: "https://github.com/csboo/apollo/pull/4", "work-in-progress" } - br {} - "meanwhile, make sure to check out our " - a { href: "https://github.com/csboo/apollo", "github page" } + ToastProvider { + Router:: {} } } } diff --git a/src/app/admin.rs b/src/app/admin.rs new file mode 100644 index 0000000..0425949 --- /dev/null +++ b/src/app/admin.rs @@ -0,0 +1,61 @@ +#![deny(clippy::unwrap_used)] +#![forbid(unsafe_code)] + +use crate::app::components::admin; +use crate::app::home::AuthState; +use crate::backend::models::PuzzleSolutions; +use dioxus::prelude::*; + +#[component] +pub fn Admin() -> Element { + let 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 parsed_puzzles = use_signal(PuzzleSolutions::new); + + rsx! { + div { class: "min-h-screen max-w-4xl mx-auto py-8", + // Header + header { class: "mb-8", + h1 { class: "text-2xl font-semibold tracking-tight text-(--text-primary)", + "Admin Panel" + } + p { class: "text-(--text-muted) mt-1", + "Feladatok és beállítások kezelése" + } + } + + // Content card + section { class: "bg-(--bg-elevated) rounded-xl border border-(--border-subtle) p-6", + if auth().joined { + div { class: "mb-5", + h2 { class: "text-lg font-medium text-(--text-primary)", + "Feladat beállítása" + } + } + + div { class: "flex flex-wrap gap-4 items-end", + admin::TaskManager { + auth, + puzzle_id, + puzzle_value, + puzzle_solution, + parsed_puzzles, + } + } + } else { + div { class: "mb-5", + h2 { class: "text-lg font-medium text-(--text-primary)", + "Bejelentkezés" + } + } + + div { class: "flex flex-wrap gap-4 items-end", + admin::Login { auth } + } + } + } + } + } +} diff --git a/src/app/components/admin/admin.rs b/src/app/components/admin/admin.rs new file mode 100644 index 0000000..853b7f0 --- /dev/null +++ b/src/app/components/admin/admin.rs @@ -0,0 +1,100 @@ +use dioxus::prelude::*; +use dioxus_primitives::toast::use_toast; + +use crate::{ + app::components::{ + self, + general::UserType::Admin, + tailwind_constants::{BUTTON, CSV_INPUT, INPUT}, + }, + app::home::{AuthState, actions}, + backend::models::PuzzleSolutions, +}; + +#[component] +pub fn Login(mut auth: Signal) -> Element { + rsx!(components::general::Login { + auth, + usertype: Admin + }) +} + +#[component] +pub fn TaskManager( + mut auth: Signal, + puzzle_id: Signal, + puzzle_value: Signal, + puzzle_solution: Signal, + parsed_puzzles: Signal, +) -> Element { + let toast_api = use_toast(); + let auth_current = auth.read().clone(); + + rsx! { + div { class: "space-y-1.5", + label { class: "block text-sm font-medium text-(--text-secondary)", + "Feladat azonosító" + } + input { class: "{INPUT}", + r#type: "text", + placeholder: "pl. task-1", + value: "{puzzle_id}", + oninput: move |evt| puzzle_id.set(evt.value()) + } + } + + div { class: "space-y-1.5", + label { class: "block text-sm font-medium text-(--text-secondary)", + "Megoldás" + } + input { class: "{INPUT}", + r#type: "text", + placeholder: "Helyes válasz", + value: "{puzzle_solution}", + oninput: move |evt| puzzle_solution.set(evt.value()) + } + } + + div { class: "space-y-1.5", + label { class: "block text-sm font-medium text-(--text-secondary)", + "Pontérték" + } + input { class: "{INPUT}", + r#type: "text", + placeholder: "pl. 100", + value: "{puzzle_value}", + oninput: move |evt| puzzle_value.set(evt.value()) + } + } + + div { class: "space-y-1.5", + label { class: "block text-sm font-medium text-(--text-secondary)", + "CSV import" + } + input { class: "{CSV_INPUT}", + r#type: "file", + r#accept: ".csv", + onchange: actions::handle_csv(parsed_puzzles, toast_api), + } + } + + div { class: "space-y-1.5", + label { class: "block text-sm font-medium text-(--text-secondary)", + "Admin jelszó" + } + input { class: "{INPUT}", + r#type: "password", + placeholder: "Jelszó megerősítése", + value: "{auth_current.password}", + oninput: move |evt| auth.write().password = evt.value() + } + } + + div { class: "flex items-end pt-2", + button { class: "{BUTTON}", + onclick: actions::handle_admin_submit(auth, puzzle_id, puzzle_value, puzzle_solution, parsed_puzzles, toast_api), + "Beállítás" + } + } + } +} diff --git a/src/app/components/admin/mod.rs b/src/app/components/admin/mod.rs new file mode 100644 index 0000000..36e3233 --- /dev/null +++ b/src/app/components/admin/mod.rs @@ -0,0 +1,2 @@ +mod admin; +pub use admin::*; diff --git a/src/app/components/alert_dialog/component.rs b/src/app/components/alert_dialog/component.rs new file mode 100644 index 0000000..2cd3c2b --- /dev/null +++ b/src/app/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/app/components/alert_dialog/mod.rs b/src/app/components/alert_dialog/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/src/app/components/alert_dialog/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/src/app/components/alert_dialog/style.css b/src/app/components/alert_dialog/style.css new file mode 100644 index 0000000..7214a3d --- /dev/null +++ b/src/app/components/alert_dialog/style.css @@ -0,0 +1,175 @@ +/* ============================================ + ALERT DIALOG COMPONENT STYLES +============================================ */ + +/* Backdrop */ +.alert-dialog-backdrop { + position: fixed; + z-index: 1000; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} + +.alert-dialog-backdrop[data-state="closed"] { + animation: backdrop-out 150ms ease-in forwards; +} + +@keyframes backdrop-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.alert-dialog-backdrop[data-state="open"] { + animation: backdrop-in 150ms ease-out forwards; +} + +@keyframes backdrop-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Dialog container */ +.alert-dialog { + position: fixed; + z-index: 1001; + top: 50%; + left: 50%; + display: flex; + width: 100%; + max-width: calc(100% - var(--space-8)); + box-sizing: border-box; + flex-direction: column; + padding: var(--space-8) var(--space-6) var(--space-6); + border: 1px solid var(--border-default); + border-radius: var(--radius-xl); + background: var(--bg-elevated); + box-shadow: var(--shadow-xl); + font-family: Inter, system-ui, -apple-system, sans-serif; + gap: var(--space-4); + text-align: center; + transform: translate(-50%, -50%); +} + +.alert-dialog[data-state="open"] { + animation: dialog-in 200ms ease-out forwards; +} + +.alert-dialog[data-state="closed"] { + animation: dialog-out 150ms ease-in forwards; +} + +@keyframes dialog-in { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes dialog-out { + from { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + to { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } +} + +.alert-dialog-title { + margin: 0; + color: var(--text-primary); + font-size: var(--text-xl); + font-weight: 700; + letter-spacing: -0.02em; +} + +.alert-dialog-description { + margin: 0; + color: var(--text-secondary); + font-size: var(--text-base); + line-height: 1.6; +} + +.alert-dialog-actions { + display: flex; + flex-direction: column-reverse; + gap: var(--space-3); + margin-top: var(--space-2); +} + +@media (min-width: 40rem) { + .alert-dialog-actions { + flex-direction: row; + justify-content: flex-end; + } + + .alert-dialog { + max-width: 28rem; + text-align: left; + } +} + +/* Cancel button */ +.alert-dialog-cancel { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-2) var(--space-5); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: transparent; + color: var(--text-primary); + cursor: pointer; + font-size: var(--text-sm); + font-weight: 500; + transition: all var(--transition-fast); +} + +.alert-dialog-cancel:hover { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +.alert-dialog-cancel:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-primary); +} + +/* Action/confirm button (destructive) */ +.alert-dialog-action { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-2) var(--space-5); + border: none; + border-radius: var(--radius-md); + background: var(--accent-danger); + color: white; + cursor: pointer; + font-size: var(--text-sm); + font-weight: 500; + transition: all var(--transition-fast); +} + +.alert-dialog-action:hover { + background: var(--accent-danger-hover); +} + +.alert-dialog-action:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-danger); +} diff --git a/src/app/components/general/general.rs b/src/app/components/general/general.rs new file mode 100644 index 0000000..91cb9da --- /dev/null +++ b/src/app/components/general/general.rs @@ -0,0 +1,75 @@ +use dioxus::prelude::*; +use dioxus_primitives::toast::use_toast; + +use crate::{ + app::components::tailwind_constants::{BUTTON, INPUT}, + app::home::{AuthState, actions}, +}; + +#[derive(Clone, PartialEq)] +pub enum UserType { + Admin, + Player, +} + +#[component] +pub fn Login(mut auth: Signal, usertype: UserType) -> Element { + let toast_api = use_toast(); + let mut is_contestant_ready = use_signal(|| false); + use_future(move || async move { + *is_contestant_ready.write() = crate::backend::endpoints::contestant_ready().await.is_ok() + }); + + rsx! { + if usertype == UserType::Player { + div { class: "space-y-1.5", + label { class: "block text-sm font-medium text-(--text-secondary)", + "Csapatnév" + } + input { class: INPUT, + r#type: "text", + placeholder: "Add meg a csapatnevet", + value: "{auth().username}", + oninput: move |evt| auth.write().username = evt.value() + } + } + } + + if usertype == UserType::Admin { + div { class: "space-y-1.5", + label { class: "block text-sm font-medium text-(--text-secondary)", + "Jelszó" + } + if !*is_contestant_ready.read() { + input { class: "{INPUT}", + r#type: "password", + placeholder: "Elsődleges admin jelszó", + value: "{auth().init_password}", + cursor: "text", + oninput: move |evt| auth.write().init_password = evt.value() + } + } + input { class: "{INPUT}", + r#type: "password", + placeholder: "Add meg a jelszót", + value: "{auth().password}", + oninput: move |evt| auth.write().password = evt.value() + } + } + } + + div { class: "flex items-end", + if usertype == UserType::Admin { + button { class: "{BUTTON}", + onclick: actions::handle_admin_join(auth, toast_api), + "Bejelentkezés" + } + } else { + button { class: "{BUTTON}", + onclick: actions::handle_user_join(auth, toast_api), + "Bejelentkezés" + } + } + } + } +} diff --git a/src/app/components/general/mod.rs b/src/app/components/general/mod.rs new file mode 100644 index 0000000..90f0ad6 --- /dev/null +++ b/src/app/components/general/mod.rs @@ -0,0 +1,2 @@ +mod general; +pub use general::*; diff --git a/src/app/components/home/home.rs b/src/app/components/home/home.rs new file mode 100644 index 0000000..00c7407 --- /dev/null +++ b/src/app/components/home/home.rs @@ -0,0 +1,83 @@ +use dioxus::prelude::*; +use dioxus_primitives::toast::use_toast; + +use crate::{ + app::components::{ + self, + general::UserType::Player, + tailwind_constants::{BUTTON, INPUT, SELECT}, + }, + app::home::{AuthState, actions}, + backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, +}; + +#[component] +pub fn Login(mut auth: Signal) -> Element { + rsx!(components::general::Login { + auth, + usertype: Player + }) +} + +#[component] +pub fn TaskManager( + mut auth: Signal, + puzzle_id: Signal, + puzzle_solution: Signal, + mut teams_state: Signal>, + puzzles: Signal>, +) -> Element { + let auth_current = auth.read().clone(); + let teams = teams_state.read(); + let ref_puzzles = puzzles.read(); + let toast_api = use_toast(); + + let solved = teams + .iter() + .find(|(team, _)| team == &auth_current.username) + .map(|(_, solved)| solved); + + let selectopts = solved + .into_iter() + .flat_map(|solved| ref_puzzles.iter().filter(|(id, _)| !solved.contains(id))); + + rsx! { + div { class: "space-y-1.5", + label { class: "block text-sm font-medium text-(--text-secondary)", + "Feladat" + } + select { + class: "{SELECT}", + onchange: move |evt: Event| { + debug!("{}", evt.value()); + puzzle_id.set(evt.value()); + }, + if puzzle_id.is_empty() { + option { disabled: true, selected: true, "Válassz feladatot..." } + } + for (id, _) in selectopts { + option { value: "{id}", "{id}" } + } + } + } + + div { class: "space-y-1.5", + label { class: "block text-sm font-medium text-(--text-secondary)", + "Megoldás" + } + input { class: "{INPUT}", + r#type: "text", + placeholder: "Add meg a megoldást", + value: "{puzzle_solution}", + oninput: move |evt| puzzle_solution.set(evt.value()) + } + } + + div { class: "flex items-end", + button { class: "{BUTTON}", + onclick: actions::handle_user_submit(puzzle_id, puzzle_solution, toast_api), + "Beküldés" + } + } + } +} diff --git a/src/app/components/home/mod.rs b/src/app/components/home/mod.rs new file mode 100644 index 0000000..7c8999e --- /dev/null +++ b/src/app/components/home/mod.rs @@ -0,0 +1,8 @@ +mod home; +mod score_table; +mod team_section; +mod team_status; + +pub use home::*; +pub use score_table::*; +pub use team_section::*; diff --git a/src/app/components/home/score_table.rs b/src/app/components/home/score_table.rs new file mode 100644 index 0000000..7d79462 --- /dev/null +++ b/src/app/components/home/score_table.rs @@ -0,0 +1,100 @@ +use dioxus::prelude::*; + +use crate::backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}; + +#[component] +pub fn ScoreTable( + puzzles: Signal>, + teams_state: Signal>, + is_fullscreen: bool, + toggle_fullscreen: EventHandler, +) -> Element { + let puzzle_limit = if is_fullscreen { usize::MAX } else { 3 }; + let team_limit = if is_fullscreen { usize::MAX } else { 3 }; + let has_data = !puzzles.read().is_empty() || !teams_state.read().is_empty(); + + rsx! { + div { + class: format!("score-table-shell {}", if is_fullscreen { "w-full" } else { "score-table-shell-preview" }), + onclick: toggle_fullscreen, + cursor: "pointer", + + if has_data { + table { class: "score-table w-full", + thead { + tr { + th { class: "text-left h-14 px-4 bg-(--bg-elevated) text-(--text-secondary) text-sm font-medium", + "Csapat" + } + for (id, value) in puzzles.read().iter().take(puzzle_limit) { + th { class: "h-14 px-4 bg-(--bg-elevated) text-center", + div { class: "text-sm font-medium text-(--text-primary)", + "{id}" + } + div { class: "text-xs text-(--text-muted) mt-0.5", + "{value} pont" + } + } + } + } + } + tbody { + for (team_name, solved) in teams_state.read().iter().take(team_limit) { + tr { class: "group", + td { class: "text-left px-4 py-3 font-medium text-(--text-primary) bg-(--bg-elevated) group-hover:bg-(--bg-surface) transition-colors", + "{team_name}" + } + for (puzzle_id, _puzzle) in puzzles.read().iter().take(puzzle_limit) { + td { class: "text-center py-3 bg-(--bg) group-hover:bg-(--bg-elevated) transition-colors", + if solved.contains(puzzle_id) { + span { class: "inline-flex items-center justify-center w-7 h-7 rounded-full bg-(--accent-success)/15 text-(--accent-success)", + "✓" + } + } + } + } + } + } + } + } + } else { + // Empty state + div { class: "flex flex-col items-center justify-center py-16 px-6 text-center", + div { class: "w-14 h-14 mb-4 rounded-full bg-(--bg-surface) flex items-center justify-center text-2xl", + svg { + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + view_box: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "size-6", + + path { + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z" + } + path { + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M13.5 10.5H21A7.5 7.5 0 0 0 13.5 3v7.5Z" + } + } + } + p { class: "text-(--text-muted) text-sm", + "Még nincsenek adatok" + } + } + } + + // Preview overlay + if !is_fullscreen && has_data { + div { class: "score-table-preview-overlay", + span { class: "score-table-preview-overlay-text", + "Kattints a teljes táblához" + } + } + } + } + } +} diff --git a/src/app/components/home/team_section.rs b/src/app/components/home/team_section.rs new file mode 100644 index 0000000..b294508 --- /dev/null +++ b/src/app/components/home/team_section.rs @@ -0,0 +1,79 @@ +use dioxus::prelude::*; +use dioxus_primitives::toast::use_toast; + +use crate::{ + app::components::{alert_dialog::*, home::team_status::*, tailwind_constants::BUTTON_RED}, + app::home::{AuthState, actions::handle_logout, utils::get_points_of}, + backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, +}; + +#[component] +pub fn TeamSection( + auth: Signal, + mut logout_alert: Signal, + mut delete_alert: Signal, + mut teams_state: Signal>, + puzzles: Signal>, +) -> Element { + // let auth_current = auth.read().clone(); + let toast_api = use_toast(); + let points = teams_state + .read() + .iter() + .find(|(team, _)| *team == auth().username) + .map(|team| get_points_of(team, puzzles())) + .unwrap_or(0); + + rsx! { + // Team status + TeamStatus { + team: auth().username, + points: points, + } + + // Action buttons + div { class: "flex gap-3", + button { class: "{BUTTON_RED}", + onclick: move |_| logout_alert.set(true), + "Kijelentkezés" + } + + button { class: "{BUTTON_RED} opacity-70 hover:opacity-100", + onclick: move |_| delete_alert.set(true), + "Csapat törlése" + } + } + + // Logout dialog + AlertDialogRoot { open: logout_alert(), on_open_change: move |v| logout_alert.set(v), + AlertDialogContent { + AlertDialogTitle { "Kijelentkezés" } + AlertDialogDescription { + "Biztosan ki szeretnél lépni?" + br {} + span { class: "text-(--text-muted)", + "Később visszaléphetsz, a progresszió megmarad." + } + } + AlertDialogActions { + AlertDialogCancel { "Mégsem" } + AlertDialogAction { on_click: handle_logout(auth, toast_api, false), "Kilépés" } + } + } + } + + // Delete dialog + AlertDialogRoot { open: delete_alert(), on_open_change: move |v| delete_alert.set(v), + AlertDialogContent { + AlertDialogTitle { "Csapat törlése" } + AlertDialogDescription { + "Ez véglegesen törli a csapat minden adatát." + } + AlertDialogActions { + AlertDialogCancel { "Mégsem" } + AlertDialogAction { on_click: handle_logout(auth, toast_api, true), "Törlés" } + } + } + } + } +} diff --git a/src/app/components/home/team_status.rs b/src/app/components/home/team_status.rs new file mode 100644 index 0000000..5865264 --- /dev/null +++ b/src/app/components/home/team_status.rs @@ -0,0 +1,27 @@ +use dioxus::prelude::*; + +#[component] +pub fn TeamStatus(team: String, points: u32) -> Element { + rsx!( + div { class: "bg-(--bg-elevated) rounded-xl border border-(--border-subtle) p-6", + div { class: "flex items-center justify-between", + div { + p { class: "text-sm text-(--text-muted) mb-1", + "Csapat" + } + p { class: "text-xl font-medium text-(--text-primary)", + "{team}" + } + } + div { class: "text-right", + p { class: "text-sm text-(--text-muted) mb-1", + "Pontszám" + } + p { class: "text-3xl font-semibold text-(--accent-primary)", + "{points}" + } + } + } + } + ) +} diff --git a/src/app/components/loading/loading.rs b/src/app/components/loading/loading.rs new file mode 100644 index 0000000..f76d509 --- /dev/null +++ b/src/app/components/loading/loading.rs @@ -0,0 +1,17 @@ +use dioxus::prelude::*; + +#[component] +pub fn Loading() -> Element { + rsx!( + div { class: "loading", + div { class: "flex flex-col items-center gap-6", + div { class: "relative", + div { class: "w-12 h-12 border-2 border-(--border-default) border-t-(--accent-primary) rounded-full animate-spin" } + } + p { class: "text-(--text-secondary) font-medium", + "Várakozás az Apollo kiszolgálóra..." + } + } + } + ) +} diff --git a/src/app/components/loading/mod.rs b/src/app/components/loading/mod.rs new file mode 100644 index 0000000..6ada200 --- /dev/null +++ b/src/app/components/loading/mod.rs @@ -0,0 +1,2 @@ +mod loading; +pub use loading::*; diff --git a/src/app/components/mod.rs b/src/app/components/mod.rs new file mode 100644 index 0000000..5b7ae23 --- /dev/null +++ b/src/app/components/mod.rs @@ -0,0 +1,8 @@ +mod general; + +pub mod admin; +pub mod alert_dialog; +pub mod home; +pub mod loading; +pub mod tailwind_constants; +pub mod toast; diff --git a/src/app/components/tailwind_constants.rs b/src/app/components/tailwind_constants.rs new file mode 100644 index 0000000..cb699f3 --- /dev/null +++ b/src/app/components/tailwind_constants.rs @@ -0,0 +1,128 @@ +// Design system constants - Clean modern aesthetic +// Uses CSS variables from tailwind.css + +/// Primary action button - refined with subtle glow +pub const BUTTON: &str = " + inline-flex items-center justify-center gap-2 + h-11 px-6 + rounded-lg + bg-(--accent-primary) hover:bg-(--accent-primary-hover) + text-white font-medium text-sm + border-0 + shadow-md hover:shadow-lg hover:shadow-(--accent-primary)/20 + transition-all duration-200 + focus:outline-none focus:ring-2 focus:ring-(--accent-primary)/50 focus:ring-offset-2 focus:ring-offset-(--bg) + active:scale-[0.98] + disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100 +"; + +/// Danger button - clear and intentional +pub const BUTTON_RED: &str = " + inline-flex items-center justify-center gap-2 + h-10 px-5 + rounded-lg + bg-(--accent-danger) hover:bg-(--accent-danger-hover) + text-white font-medium text-sm + border-0 + shadow-sm hover:shadow-md hover:shadow-(--accent-danger)/20 + transition-all duration-200 + focus:outline-none focus:ring-2 focus:ring-(--accent-danger)/50 focus:ring-offset-2 focus:ring-offset-(--bg) + active:scale-[0.98] +"; + +// Secondary button - ghost style +// deprecated +// pub const BUTTON_SECONDARY: &str = " +// inline-flex items-center justify-center gap-2 +// h-11 px-6 +// rounded-lg +// bg-transparent hover:bg-(--bg-surface) +// text-(--text-primary) font-medium text-sm +// border border-(--border-default) hover:border-(--border-strong) +// transition-all duration-200 +// focus:outline-none focus:ring-2 focus:ring-(--accent-primary)/50 focus:ring-offset-2 focus:ring-offset-(--bg) +// active:scale-[0.98] +// "; + +/// Text input - clean and functional +pub const INPUT: &str = " + w-full max-w-[280px] h-11 px-4 + rounded-lg + bg-(--bg-surface) + text-(--text-primary) text-sm + placeholder-(--text-muted) + border border-(--border-default) + hover:border-(--border-strong) + focus:outline-none focus:border-(--accent-primary) focus:ring-1 focus:ring-(--accent-primary)/30 + transition-all duration-200 +"; + +/// Select dropdown - matching input style +pub const SELECT: &str = " + w-full max-w-[280px] h-11 px-4 + rounded-lg + bg-(--bg-surface) + text-(--text-primary) text-sm + border border-(--border-default) + hover:border-(--border-strong) + focus:outline-none focus:border-(--accent-primary) focus:ring-1 focus:ring-(--accent-primary)/30 + transition-all duration-200 + appearance-none cursor-pointer +"; + +/// File input - subtle and modern +pub const CSV_INPUT: &str = " + w-full px-4 py-3 + rounded-lg + bg-(--bg-surface) + text-(--text-secondary) text-sm + border border-dashed border-(--border-default) + hover:border-(--accent-primary)/50 hover:bg-(--bg-hover) + focus:outline-none focus:border-(--accent-primary) + transition-all duration-200 + cursor-pointer + file:mr-4 file:py-2 file:px-4 + file:rounded-md file:border-0 + file:text-sm file:font-medium + file:bg-(--accent-primary) file:text-white + file:cursor-pointer file:transition-colors + file:hover:bg-(--accent-primary-hover) +"; + +// Subtle shine effect - refined +// deprecated :( +// pub const FLASH: &str = " +// relative overflow-hidden +// before:pointer-events-none before:absolute +// before:inset-0 +// before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent +// before:translate-x-[-200%] +// before:transition-transform before:duration-700 before:ease-out +// hover:before:translate-x-[200%] +// "; + +// Card container +// deprecated +// pub const CARD: &str = " +// p-6 +// rounded-xl +// bg-(--bg-elevated) +// border border-(--border-subtle) +// shadow-lg +// "; + +// Section title +// deprecated +// pub const SECTION_TITLE: &str = " +// text-2xl font-semibold +// text-(--text-primary) +// tracking-tight +// "; + +// Form label +// deprecated +// pub const LABEL: &str = " +// text-sm font-medium +// text-(--text-secondary) +// mb-2 +// "; diff --git a/src/app/components/toast/component.rs b/src/app/components/toast/component.rs new file mode 100644 index 0000000..4b6878f --- /dev/null +++ b/src/app/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/app/components/toast/mod.rs b/src/app/components/toast/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/src/app/components/toast/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/src/app/components/toast/style.css b/src/app/components/toast/style.css new file mode 100644 index 0000000..b912b52 --- /dev/null +++ b/src/app/components/toast/style.css @@ -0,0 +1,200 @@ +/* ============================================ + TOAST COMPONENT STYLES + ============================================ */ + +.toast-container { + position: fixed; + z-index: 9999; + right: var(--space-6); + top: var(--space-6); + max-width: 380px; +} + +.toast-list { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + gap: var(--space-3); +} + +.toast-item { + display: flex; +} + +.toast { + z-index: calc(var(--toast-count) - var(--toast-index)); + display: flex; + overflow: hidden; + width: 100%; + min-width: 320px; + box-sizing: border-box; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-5); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + margin-bottom: calc(-1 * var(--space-4) - 3rem); + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); + filter: brightness(calc(1 - var(--toast-index) * 0.08)); + opacity: calc(1 - var(--toast-hidden)); + transform: scale( + calc(100% - var(--toast-index) * 4%), + calc(100% - var(--toast-index) * 2%) + ); + transition: transform var(--transition-base), + margin-top var(--transition-base), + opacity var(--transition-base), + filter var(--transition-base); + + --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) * 4%), + calc(100% - var(--toast-index) * 2%) + ); + } + to { + transform: translateY(0) + scale( + calc(100% - var(--toast-index) * 4%), + calc(100% - var(--toast-index) * 2%) + ); + } +} + +@keyframes slide-up-odd { + from { + transform: translateY(0.5rem) + scale( + calc(100% - var(--toast-index) * 4%), + calc(100% - var(--toast-index) * 2%) + ); + } + to { + transform: translateY(0) + scale( + calc(100% - var(--toast-index) * 4%), + calc(100% - var(--toast-index) * 2%) + ); + } +} + +.toast[data-top] { + animation: slide-in 0.25s ease-out; +} + +.toast-container:has(.toast-item + .toast-item):hover .toast[data-top], +.toast-container:has(.toast-item + .toast-item):focus-within .toast[data-top] { + animation: none; +} + +@keyframes slide-in { + from { + opacity: 0; + transform: translateY(100%) scale(1.02); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.toast-container:has(.toast-item + .toast-item):hover .toast, +.toast-container:has(.toast-item + .toast-item):focus-within .toast { + margin-bottom: var(--space-1); + filter: brightness(1); + opacity: 1; + transform: scale(1); +} + +/* Toast type variants */ +.toast[data-type="success"] { + background: linear-gradient(135deg, var(--bg-elevated), rgba(16, 185, 129, 0.1)); + border-color: var(--accent-success); +} + +.toast[data-type="success"] .toast-title { + color: var(--accent-success); +} + +.toast[data-type="error"] { + background: linear-gradient(135deg, var(--bg-elevated), rgba(239, 68, 68, 0.1)); + border-color: var(--accent-danger); +} + +.toast[data-type="error"] .toast-title { + color: var(--accent-danger); +} + +.toast[data-type="warning"] { + background: linear-gradient(135deg, var(--bg-elevated), rgba(245, 158, 11, 0.1)); + border-color: var(--accent-warning); +} + +.toast[data-type="warning"] .toast-title { + color: var(--accent-warning); +} + +.toast[data-type="info"] { + background: linear-gradient(135deg, var(--bg-elevated), rgba(59, 130, 246, 0.1)); + border-color: var(--accent-primary); +} + +.toast[data-type="info"] .toast-title { + color: var(--accent-primary); +} + +.toast-content { + flex: 1; + margin-right: var(--space-3); +} + +.toast-title { + margin-bottom: var(--space-1); + color: var(--text-primary); + font-weight: 600; + font-size: var(--text-sm); +} + +.toast-description { + color: var(--text-secondary); + font-size: var(--text-sm); + line-height: 1.5; +} + +.toast-close { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + transition: all var(--transition-fast); +} + +.toast-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} diff --git a/src/app/home.rs b/src/app/home.rs new file mode 100644 index 0000000..9c6926f --- /dev/null +++ b/src/app/home.rs @@ -0,0 +1,128 @@ +#![deny(clippy::unwrap_used)] +#![forbid(unsafe_code)] + +use dioxus::prelude::*; + +pub mod actions; +mod hooks; +mod models; +pub mod utils; + +pub use crate::app::home::models::AuthState; + +use crate::{ + app::components::{home, loading}, + backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, +}; + +#[component] +pub fn Home() -> Element { + trace!("kicking off app"); + trace!("initing variables"); + let puzzle_id = use_signal(String::new); + let puzzle_solution = use_signal(String::new); + let auth = use_signal(AuthState::default); + let teams_state = use_signal(Vec::<(String, SolvedPuzzles)>::new); + let puzzles = use_signal(Vec::<(PuzzleId, PuzzleValue)>::new); + let title = use_signal(|| None::); + let is_fullscreen = use_signal(|| false); + let logout_alert = use_signal(|| false); + let delete_alert = use_signal(|| false); + + trace!("variables inited"); + + hooks::check_auth(auth); + hooks::load_title(title); + hooks::subscribe_stream(teams_state, puzzles); + + if title().is_none() { + return loading::Loading(); + } + + rsx! { + div { + class: if *is_fullscreen.read() { "table-only" } else { "normal min-h-screen" }, + + // Header // TODO move into own Element + div { class: "others-container max-w-5xl mx-auto", + if let Some(t) = &*title.read() { + header { class: "pt-4 pb-8", + h1 { class: "text-3xl font-semibold tracking-tight text-(--text-primary)", + "{t}" + } + } + } + } + + // Fullscreen table // TODO maybe move into own Element ? + if is_fullscreen() { + div { class: "table-overlay", + div { class: "table-window", + // TODO this maybe needed somewhere here style: "-webkit-overflow-scrolling: touch;" + div { class: "table-viewport", + home::ScoreTable { + puzzles: puzzles, + teams_state: teams_state, + is_fullscreen: true, + toggle_fullscreen: actions::toggle_fullscreen(is_fullscreen), + } + } + } + } + } else { + // Score table preview + div { class: "max-w-5xl mx-auto", + home::ScoreTable { + puzzles: puzzles, + teams_state: teams_state, + is_fullscreen: false, + toggle_fullscreen: actions::toggle_fullscreen(is_fullscreen), + } + } + } + + // Main content // TODO move into own Element + div { class: "others-container max-w-5xl mx-auto mt-8 space-y-6", + if auth().joined { + // Submit card + section { class: "bg-(--bg-elevated) rounded-xl border border-(--border-subtle) p-6", + div { class: "mb-5", + h2 { class: "text-lg font-medium text-(--text-primary)", + "Megoldás beküldése" + } + } + div { class: "flex flex-wrap gap-4 items-end", + home::TaskManager { + auth, + puzzle_id, + puzzle_solution, + teams_state, + puzzles, + } + } + } + + home::TeamSection { + auth, + logout_alert, + delete_alert, + teams_state, + puzzles, + } + } else { + // Login card + section { class: "bg-(--bg-elevated) rounded-xl border border-(--border-subtle) p-6", + div { class: "mb-5", + h2 { class: "text-lg font-medium text-(--text-primary)", + "Bejelentkezés" + } + } + div { class: "flex flex-wrap gap-4 items-end", + home::Login { auth } + } + } + } + } + } + } +} diff --git a/src/app/home/actions.rs b/src/app/home/actions.rs new file mode 100644 index 0000000..fa9ee49 --- /dev/null +++ b/src/app/home/actions.rs @@ -0,0 +1,207 @@ +use dioxus::{prelude::*, signals::Signal}; +use dioxus_primitives::toast::Toasts; + +use crate::{ + app::home::{ + models::AuthState, + utils::{ + parse_puzzle_csv, popup_error, popup_normal, popup_success, validate_puzzle_id, + validate_puzzle_solution, validate_puzzle_value, + }, + }, + backend::models::{Puzzle, PuzzleSolutions}, +}; + +pub fn handle_user_join( + mut auth: Signal, + toast_api: Toasts, +) -> impl FnMut(Event) + 'static { + move |_| { + spawn(async move { + if !auth.read().validate_username(toast_api) { + return; + } + + match crate::backend::endpoints::join(auth().username).await { + Ok(_) => { + popup_success(toast_api, format!("Üdv, {}", auth.read().username)); + auth.write().joined = true; // TODO auth.reset(_somefield) + auth.write().init_password = String::new(); + auth.write().password = String::new(); + } + Err(e) => popup_error(toast_api, e), + } + }); // spawn async move + } // move +} + +pub fn handle_admin_join( + mut auth: Signal, + toast_api: Toasts, +) -> impl FnMut(Event) + 'static { + move |_| { + spawn(async move { + if !auth.read().validate_password(toast_api) { + return; + } + + let should_have_initial = crate::backend::endpoints::contestant_ready().await.is_ok(); + let res = if should_have_initial { + crate::backend::endpoints::admin_auth_state(auth().password).await + } else { + if !auth.read().validate_init_password(toast_api) { + return; + } + crate::backend::endpoints::set_passwd(auth().init_password, auth().password).await + }; + + match res { + Ok(msg) => { + auth.write().joined = true; + popup_normal(toast_api, msg); + } + Err(e) => popup_error(toast_api, e), + } + }); // spawn async move + } // move +} + +pub fn handle_user_submit( + mut puzzle_id: Signal, + mut puzzle_solution: Signal, + toast_api: Toasts, +) -> impl FnMut(Event) + 'static { + move |_| { + spawn(async move { + let puzzle_current = puzzle_id.read().clone(); + let solution_current = puzzle_solution.read().clone(); + if !validate_puzzle_id(&puzzle_current, toast_api) { + return; + } + if !validate_puzzle_solution(&solution_current, toast_api) { + return; + } + + match crate::backend::endpoints::submit_solution(puzzle_current, solution_current).await + { + Ok(msg) => { + popup_normal(toast_api, msg); + puzzle_id.set(String::new()); + puzzle_solution.set(String::new()); + } + Err(e) => { + popup_error(toast_api, e); + } + } + }); + } +} + +pub fn handle_admin_submit( + auth: Signal, + mut puzzle_id: Signal, + mut puzzle_value: Signal, + mut puzzle_solution: Signal, + parsed_puzzles: Signal, + toast_api: Toasts, +) -> impl FnMut(Event) + 'static { + move |_| { + spawn(async move { + match crate::backend::endpoints::set_solution( + if parsed_puzzles.read().is_empty() { + if !validate_puzzle_id(&puzzle_id.read().clone(), toast_api) { + return; + } + if !validate_puzzle_solution(&puzzle_solution.read().clone(), toast_api) { + return; + } + if !validate_puzzle_value(&puzzle_value.read().clone(), toast_api) { + return; + } + + debug!("parsed puzzles is empty, trying from manual values"); + let Ok(value_current) = puzzle_value.read().parse() else { + popup_error(toast_api, "Az érték csak szám lehet"); + return; + }; + PuzzleSolutions::from([( + puzzle_id.read().clone(), + Puzzle { + value: value_current, + solution: puzzle_solution.read().clone(), + }, + )]) + } else { + parsed_puzzles.read().clone() + }, + auth.read().cloned().password, + ) + .await + { + Ok(msg) => { + popup_normal(toast_api, msg); + puzzle_id.set(String::new()); + puzzle_solution.set(String::new()); + puzzle_value.set(String::new()); + // password.set(String::new()); NOTE should remember password? + } + Err(e) => { + popup_error(toast_api, e); + } + } + }); + } +} + +pub fn handle_csv( + mut parsed_puzzles: Signal, + toast_api: Toasts, +) -> impl FnMut(Event) + 'static { + move |form_data| { + spawn(async move { + if let Some(file) = form_data.files().first() { + let Ok(text) = file.read_string().await else { + warn!("couldn't parse text from selected file"); + return; + }; + parsed_puzzles.set(parse_puzzle_csv(&text, toast_api)); + debug!("set puzzles from csv"); + } else { + warn!("couldn't read selected file"); + }; + }); + } +} + +pub fn toggle_fullscreen( + mut is_fullscreen: Signal, +) -> impl FnMut(Event) + 'static { + move |_| { + trace!("fullscreen toggle called"); + is_fullscreen.set(!is_fullscreen()); + } +} + +pub fn handle_logout( + mut auth: Signal, + toast_api: Toasts, + superlogout: bool, +) -> impl FnMut(Event) + 'static { + let wipe = match superlogout { + true => Some(true), + false => None, + }; + move |_| { + spawn(async move { + match crate::backend::endpoints::logout(wipe).await { + Ok(_) => { + popup_normal(toast_api, format!("Viszlát, {}", auth.read().username)); + auth.set(AuthState::default()); + } + Err(e) => { + popup_error(toast_api, e); + } + } + }); + } +} diff --git a/src/app/home/hooks.rs b/src/app/home/hooks.rs new file mode 100644 index 0000000..2cdb106 --- /dev/null +++ b/src/app/home/hooks.rs @@ -0,0 +1,51 @@ +use dioxus::prelude::*; + +use crate::{ + app::home::{AuthState, utils::get_points_of}, + backend::models::{PuzzleId, PuzzleValue, SolvedPuzzles}, +}; + +pub fn load_title(mut title: Signal>) { + use_future(move || async move { + let result = crate::backend::endpoints::event_title() + .await + // .inspect_err(|e| popup_error(message, format!("Hiba: {}", e))) //TODO WARN + .ok(); + + title.set(result.unwrap_or_else(|| "Apollo esemény".into()).into()); + }); +} + +pub fn check_auth(mut auth: Signal) { + use_future(move || async move { + if let Ok(name) = crate::backend::endpoints::auth_state().await { + auth.write().username = name.clone(); + auth.write().joined = true; + // popup_normal(message, format!("Üdv újra, {name}")); //TODO WARN + } + }); +} + +pub fn subscribe_stream( + mut teams_state: Signal>, + mut puzzles: Signal>, +) { + use_future(move || async move { + let mut stream = crate::backend::endpoints::state_stream().await?; // TODO WARN error handling + while let Some(Ok((new_team_state, new_puzzles))) = stream.next().await { + let mut puzzles_sorted: Vec<_> = new_puzzles.into_iter().collect(); + puzzles_sorted.sort_by(|p1, p2| p1.1.cmp(&p2.1).then_with(|| p1.0.cmp(&p2.0))); + + let mut teams_sorted: Vec<_> = new_team_state.into_iter().collect(); + teams_sorted.sort_by(|a, b| { + get_points_of(b, puzzles.read().clone()) + .cmp(&get_points_of(a, puzzles.read().clone())) + .then_with(|| a.0.cmp(&b.0)) + }); + + puzzles.set(puzzles_sorted); + teams_state.set(teams_sorted); + } + dioxus::Ok(()) + }); +} diff --git a/src/app/home/models.rs b/src/app/home/models.rs new file mode 100644 index 0000000..64c1465 --- /dev/null +++ b/src/app/home/models.rs @@ -0,0 +1,50 @@ +use dioxus_primitives::toast::Toasts; + +use crate::app::home::utils::popup_error; + +#[derive(Default, Clone, PartialEq)] +pub struct AuthState { + pub(crate) username: String, + pub(crate) init_password: String, + pub(crate) password: String, + pub(crate) joined: bool, +} + +impl AuthState { + pub fn validate_username(&self, toast_api: Toasts) -> bool { + if self.username.is_empty() { + popup_error(toast_api, "A csapatnév nem lehet üres"); + return false; + } + true + } + pub fn validate_password(&self, toast_api: Toasts) -> bool { + if self.password.is_empty() { + popup_error(toast_api, "A jelszó nem lehet üres"); + return false; + } + true + } + pub fn validate_init_password(&self, toast_api: Toasts) -> bool { + if self.init_password.is_empty() { + popup_error(toast_api, "A beállítási jelszó nem lehet üres"); + return false; + } + true + } + + // TODO more validation (everywhere) + // pub fn validate(&self, toast_api: Toasts) -> bool { + // if !self.validate_username(toast_api) { + // return false; + // }; + // if !self.validate_password(toast_api) { + // return false; + // }; + + // true + // } + pub fn validate_admin(&self, toast_api: Toasts) -> bool { + self.validate_init_password(toast_api) && self.validate_password(toast_api) + } +} diff --git a/src/app/home/utils.rs b/src/app/home/utils.rs new file mode 100644 index 0000000..48498e0 --- /dev/null +++ b/src/app/home/utils.rs @@ -0,0 +1,120 @@ +use std::time::Duration; + +use csv::ReaderBuilder; +use dioxus_primitives::toast::{ToastOptions, Toasts}; + +use crate::backend::models::{Puzzle, PuzzleId, PuzzleSolutions, PuzzleValue, SolvedPuzzles}; + +pub fn parse_puzzle_csv(csv_text: &str, toast_api: Toasts) -> PuzzleSolutions { + let mut rdr = ReaderBuilder::new() + .has_headers(true) + .from_reader(csv_text.as_bytes()); + + let mut puzzles = PuzzleSolutions::new(); + let mut volte = false; + + for result in rdr.records() { + let record = match result { + Ok(r) => r, + Err(_e) => { + // warn!("skipping invalid CSV row: {}", e); + volte = true; + continue; + } + }; + let Some(id) = record.get(0) else { + // warn!("invalid 'id' field in CSV row: {:?}", &record); // TODO dont log value ever + volte = true; + continue; + }; + let Some(solution) = record.get(1) else { + // warn!("invalid 'solution' field in CSV row: {:?}", &record); + volte = true; + continue; + }; + let Some(value) = record.get(2) else { + // warn!("invalid 'value' field in CSV row: {:?}", &record); + volte = true; + continue; + }; + let Ok(value_num) = value.parse::() else { + // warn!( + // "value of field 'value' is not a number in CSV row: {:?}", + // &record + // ); + volte = true; + continue; + }; + + puzzles.insert( + id.into(), + Puzzle { + solution: solution.into(), + value: value_num, + }, + ); + } + + if volte { + popup_error( + toast_api, + "néhány sort nem sikerült betölteni, nézd meg a konzolt", + ); + } + + puzzles +} + +macro_rules! gen_toast { + ($func_name:ident, $kind:ident, title: $title:literal, timeout: $timeout:expr) => { + pub fn $func_name(toast_api: Toasts, text: impl std::fmt::Display) { + toast_api.$kind( + $title.to_string(), + ToastOptions::new() + .description(text) + .duration(Duration::from_secs($timeout)) + .permanent(false), + ); + } + }; +} + +gen_toast!(popup_normal, info, title: "Info", timeout: 5); +gen_toast!(popup_success, success, title: "Siker", timeout: 6); +gen_toast!(popup_error, error, title: "Hiba", timeout: 12); + +pub fn get_points_of(team: &(String, SolvedPuzzles), puzzles: Vec<(PuzzleId, PuzzleValue)>) -> u32 { + puzzles + .iter() + .filter(|(id, _)| team.1.contains(id)) + .map(|(_, value)| *value) + .sum() +} + +pub fn validate_puzzle_id(puzzle_id: &str, toast_api: Toasts) -> bool { + match !puzzle_id.is_empty() { + true => true, + false => { + popup_error(toast_api, "a feladat nem lehet üres"); + false + } + } +} +pub fn validate_puzzle_solution(puzzle_solution: &str, toast_api: Toasts) -> bool { + match !puzzle_solution.is_empty() { + true => true, + false => { + popup_error(toast_api, "a megoldás nem lehet üres"); + false + } + } +} +pub fn validate_puzzle_value(puzzle_value: &str, toast_api: Toasts) -> bool { + match !puzzle_value.is_empty() { + true => true, + false => { + popup_error(toast_api, "az érték nem lehet üres"); + false + } + } +} diff --git a/src/backend/endpoints.rs b/src/backend/endpoints.rs index 2e7c9f2..8b88400 100644 --- a/src/backend/endpoints.rs +++ b/src/backend/endpoints.rs @@ -14,6 +14,14 @@ pub async fn event_title() -> Result { Ok(EVENT_TITLE.clone()?) } +#[get("/api/contestant_ready")] +pub async fn contestant_ready() -> Result { + check_admin_pwd()?; + Ok(String::from( + "a kiszolgáló készen áll versenyzők fogadására", + )) +} + /// streams current progress of the teams and existing puzzles with their values #[get("/api/state")] pub async fn state_stream() -> Result> { @@ -117,6 +125,19 @@ pub async fn logout(wipe_progress: Option) -> Result, .or_internal_server_error("valahogy érvénytelen munkamenet-azonosító sütit generáltunk...") } +/// check whether necessary admin credential criteria are met +#[post("/api/admin_auth_state")] +pub async fn admin_auth_state(mut password: String) -> Result { + // submitting as admin + let hashed_key = check_admin_pwd()?; + let pwd_matches = argon2::verify_raw(password.as_bytes(), &*SALT, hashed_key, &ARGON2CONF) + .inspect_err(|e| error!("nem sikerült azonosítani a jelszót: {e}")) + .or_internal_server_error("nem sikerült azonosítani a jelszót")?; + password.zeroize(); + pwd_matches.or_unauthorized("érvénytelen jelszó")?; + Ok(String::from("sikeres rendszergazdai bejelentkezés")) +} + /// before this, no solution can be set, no state will be loaded /// NOTE: might take a while, as it hashes the `password` and loads the state /// NOTE: use https @@ -158,15 +179,10 @@ pub async fn set_passwd(init_password: String, mut password: String) -> Result Result { // submitting as admin - let hashed_key = check_admin_pwd()?; - let pwd_matches = argon2::verify_raw(password.as_bytes(), &*SALT, hashed_key, &ARGON2CONF) - .inspect_err(|e| error!("nem sikerült azonosítani a jelszót: {e}")) - .or_internal_server_error("nem sikerült azonosítani a jelszót")?; - password.zeroize(); - pwd_matches.or_unauthorized("érvénytelen jelszó")?; + admin_auth_state(password).await?; let puzzles_lock = PUZZLES.read().await; puzzle_solutions diff --git a/src/main.rs b/src/main.rs index 16f0837..ce650e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod backend; +mod routes; fn main() { dioxus::logger::initialize_default(); diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..4416a5d --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,13 @@ +use crate::app::admin::Admin; +use crate::app::home::Home; +use dioxus::prelude::*; +use dioxus_router::Routable; + +#[derive(Routable, Clone, PartialEq)] +pub enum ApolloRoutes { + #[route("/")] + Home {}, + + #[route("/admin")] + Admin {}, +} diff --git a/tailwind.css b/tailwind.css index f1d8c73..4ccb175 100644 --- a/tailwind.css +++ b/tailwind.css @@ -1 +1,110 @@ @import "tailwindcss"; +@import "./assets/main.css"; + +/* +======================================== + APOLLO DESIGN SYSTEM +======================================== */ +@theme { + /* + ========================== + SIZE/SCALE + ========================== */ + /* Text Scale */ + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 2rem; + --text-4xl: 2.5rem; + --text-title: clamp(1.5rem, 4vw, 2.5rem); + + /* Spacing Scale */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + + /* Border Radius */ + --radius-sm: 0.5rem; + --radius-md: 0.75rem; + --radius-lg: 1rem; + --radius-xl: 1.25rem; + --radius-2xl: 1.5rem; + --radius-full: 9999px; + + /* + ========================== + COLOR PALETTE + ========================== */ + + /* Background colors - subtle layering */ + --bg: #09090b; + --bg-elevated: #18181b; + --bg-surface: #27272a; + --bg-hover: #3f3f46; + --bg-active: #52525b; + + /* Border colors - refined opacity */ + --border-subtle: rgba(255, 255, 255, 0.04); + --border-default: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.12); + + /* Text colors - high contrast, readable */ + --text-primary: #fafafa; + --text-secondary: #a1a1aa; + --text-muted: #71717a; + --text-inverse: #09090b; + + /* Accent */ + --accent-primary: #3b82f6; + --accent-primary-hover: #60a5fa; + --accent-primary-active: #2563eb; + --accent-primary-subtle: rgba(59, 130, 246, 0.12); + + /* Success */ + --accent-success: #22c55e; + --accent-success-hover: #4ade80; + --accent-success-subtle: rgba(34, 197, 94, 0.12); + + /* Warning */ + --accent-warning: #f59e0b; + --accent-warning-hover: #fbbf24; + --accent-warning-subtle: rgba(245, 158, 11, 0.12); + + /* Danger */ + --accent-danger: #ef4444; + --accent-danger-hover: #f87171; + --accent-danger-subtle: rgba(239, 68, 68, 0.12); + + /* + ========================== + OTHERS + ========================== */ + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.3); + --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.4); + --shadow-glow: 0 0 24px rgba(59, 130, 246, 0.2); + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + + /* DEPRECATED */ + /* Legacy aliases */ + --dark1: #18181b; + --dark2: #27272a; + --middle: #3b82f6; + --light2: #60a5fa; + --light1: #fafafa; +}