diff --git a/crates/backend/src/main.rs b/crates/backend/src/main.rs index 8712085..73a6c87 100644 --- a/crates/backend/src/main.rs +++ b/crates/backend/src/main.rs @@ -12,7 +12,10 @@ use axum::Router; use axum::TypedHeader; use common::Achievement; use common::CreateAchievement; +use common::CreateMilestone; use common::DeleteAchievement; +use common::DeleteMilestone; +use common::Milestone; use common::ToggleAchievement; use serde::Deserialize; use serde::Serialize; @@ -89,8 +92,10 @@ async fn main() { tokio::spawn(async move { let app = Router::new() .route("/create", post(create_achievement)) - .route("/toggle", post(toggle_achievement)) .route("/delete", post(delete_achievement)) + .route("/toggle", post(toggle_achievement)) + .route("/create-milestone", post(create_milestone)) + .route("/delete-milestone", post(delete_milestone)) .route("/ws", get(ws_handler)) .layer( ServiceBuilder::new() @@ -178,6 +183,49 @@ async fn create_achievement( Ok((StatusCode::CREATED, ())) } +async fn create_milestone( + Extension(app_state): Extension, + Json(create_milestone): Json, +) -> Result<(StatusCode, ()), HandlerError> { + tracing::debug!("Creating milestone: {create_milestone:?}."); + + if create_milestone.goal > 100 { + return Ok((StatusCode::BAD_REQUEST, ())); + } + + let milestone = Milestone { + goal: create_milestone.goal, + uuid: uuid::Uuid::new_v4(), + }; + let mut lock = app_state.write().await; + lock.app_state.state.milestones.push(milestone); + lock.watcher_tx + .send(lock.app_state.state.clone()) + .expect("watch channel is closed, every receiver was dropped."); + Ok((StatusCode::CREATED, ())) +} + +async fn delete_milestone( + Extension(app_state): Extension, + Json(delete_milestone): Json, +) -> Result<(StatusCode, ()), HandlerError> { + tracing::debug!("Deleting milestone: {delete_milestone:?}."); + let mut lock = app_state.write().await; + if let Some(pos) = lock + .app_state + .state + .milestones + .iter() + .position(|x| x.uuid == delete_milestone.uuid) + { + lock.app_state.state.milestones.remove(pos); + lock.watcher_tx + .send(lock.app_state.state.clone()) + .expect("watch channel is closed, every receiver was dropped."); + } + Ok((StatusCode::OK, ())) +} + async fn toggle_achievement( Extension(app_state): Extension, Json(toggle_achievement): Json, diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index f3c706a..8c7b8b8 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -6,6 +6,7 @@ pub type WebSocketMessage = State; #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct State { pub achievements: Vec, + pub milestones: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -15,17 +16,33 @@ pub struct Achievement { pub uuid: uuid::Uuid, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct CreateAchievement { pub goal: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ToggleAchievement { pub uuid: uuid::Uuid, } -#[derive(Debug, Serialize, Clone, Deserialize)] +#[derive(PartialEq, Eq, Debug, Serialize, Clone, Deserialize)] pub struct DeleteAchievement { pub uuid: uuid::Uuid, } + +#[derive(Debug, PartialEq, Eq, Serialize, Clone, Deserialize)] +pub struct Milestone { + pub goal: usize, + pub uuid: uuid::Uuid, +} + +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +pub struct CreateMilestone { + pub goal: usize, +} + +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +pub struct DeleteMilestone { + pub uuid: uuid::Uuid, +} diff --git a/crates/frontend/index.html b/crates/frontend/index.html index eb35b7b..bbaa7c6 100644 --- a/crates/frontend/index.html +++ b/crates/frontend/index.html @@ -23,6 +23,8 @@ - - + + + + diff --git a/crates/frontend/src/components/create_milestone.rs b/crates/frontend/src/components/create_milestone.rs new file mode 100644 index 0000000..852a97e --- /dev/null +++ b/crates/frontend/src/components/create_milestone.rs @@ -0,0 +1,129 @@ +use crate::services::rest::RestService; +use common::CreateMilestone; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; +use web_sys::HtmlInputElement; +use yew::html; +use yew::Callback; +use yew::Component; +use yew::Html; +use yew::NodeRef; +use yew_router::scope_ext::RouterScopeExt; + +#[derive(Debug)] +pub enum Msg { + Submit, + UpdateInput(String), +} + +#[derive(Default)] +pub struct CreateMilestoneComponent { + input_value: String, + submitted: bool, + input_ref: NodeRef, +} +impl Component for CreateMilestoneComponent { + type Message = Msg; + type Properties = (); + + fn create(_ctx: &yew::Context) -> Self { + Self::default() + } + + fn rendered(&mut self, _ctx: &yew::Context, first_render: bool) { + if first_render { + if let Some(input_element) = self.input_ref.get() { + let input_element = input_element + .dyn_into::() + .expect("Failed to cast input element"); + input_element.focus().expect("Failed to focus input"); + } + } + } + + fn update(&mut self, _ctx: &yew::Context, msg: Self::Message) -> bool { + match msg { + Msg::Submit => { + log::info!("Creating achievement"); + let Ok(goal) = self.input_value.parse::() else { return false; }; + + let goal = goal as usize; + let payload = CreateMilestone { goal }; + spawn_local(async move { + match RestService::create_milestone(payload).await { + Ok(_response) => {} + Err(_err) => {} + } + }); + self.submitted = true; + + true + } + Msg::UpdateInput(value) => { + self.input_value = value; + true + } + } + } + + fn view(&self, ctx: &yew::Context) -> Html { + let link = ctx.link().clone(); + let nav = ctx.link().navigator().unwrap(); + + if self.submitted { + let onclick_go_back = Callback::from(move |_: web_sys::MouseEvent| { + nav.push(&crate::Route::Root); + }); + + html! { + <> +
+
+

{"Submitted!"}

+
+
+
+
+ +
+
+ + } + } else { + let onclick_go_back = Callback::from(move |_: web_sys::MouseEvent| { + nav.push(&crate::Route::Root); + }); + + let onsubmit = link.callback(|e: web_sys::SubmitEvent| { + e.prevent_default(); + Msg::Submit + }); + + let oninput = link.callback(|e: web_sys::InputEvent| { + let Some(input) = e + .target() + .and_then(|t| t.dyn_into::().ok()) else { unreachable!() }; + Msg::UpdateInput(input.value()) + }); + + html! { + <> +
+ +
+ +
+
+
+
+ + +
+
+ +
+ + } + } + } +} diff --git a/crates/frontend/src/components/milestone.rs b/crates/frontend/src/components/milestone.rs new file mode 100644 index 0000000..f15670f --- /dev/null +++ b/crates/frontend/src/components/milestone.rs @@ -0,0 +1,37 @@ +use common::Milestone; +use yew::prelude::*; + +#[derive(Properties, Clone, PartialEq)] +pub struct Props { + pub milestone: Milestone, + pub completed_achievements: usize, +} + +#[function_component(MilestoneComponent)] +pub fn milestone_component(props: &Props) -> Html { + let unfilled = props.milestone.goal - props.completed_achievements.min(props.milestone.goal); + let filled = props.completed_achievements.min(props.milestone.goal); + + let unfilled_stars = std::iter::repeat(html! { + + }) + .take(unfilled) + .collect::(); + + let filled_stars = std::iter::repeat(html! { + + }) + .take(filled) + .collect::(); + + html! { +
+

+ {format!("{} / {}", props.completed_achievements, props.milestone.goal)} +
+ {filled_stars} + {unfilled_stars} +

+
+ } +} diff --git a/crates/frontend/src/components/mod.rs b/crates/frontend/src/components/mod.rs index 9a60fc6..835c031 100644 --- a/crates/frontend/src/components/mod.rs +++ b/crates/frontend/src/components/mod.rs @@ -1,4 +1,6 @@ pub mod achievement; pub mod admin; pub mod create_achievement; +pub mod create_milestone; +pub mod milestone; pub mod root; diff --git a/crates/frontend/src/components/root.rs b/crates/frontend/src/components/root.rs index b80bfcb..6a97c51 100644 --- a/crates/frontend/src/components/root.rs +++ b/crates/frontend/src/components/root.rs @@ -1,4 +1,5 @@ use crate::components::achievement::AchievementComponent; +use crate::components::milestone::MilestoneComponent; use crate::event_bus::EventBus; use crate::services::websocket::WebsocketService; use std::rc::Rc; @@ -51,8 +52,15 @@ impl Component for Root { fn view(&self, ctx: &Context) -> Html { let nav = ctx.link().navigator().unwrap(); - let onclick_create_achievement = Callback::from(move |_: web_sys::MouseEvent| { - nav.push(&crate::Route::CreateAchievement); + let onclick_create_achievement = { + let nav = nav.clone(); + Callback::from(move |_: web_sys::MouseEvent| { + nav.push(&crate::Route::CreateAchievement); + }) + }; + + let onclick_create_milestone = Callback::from(move |_: web_sys::MouseEvent| { + nav.push(&crate::Route::CreateMilestone); }); let Some(state) = &self.state else { @@ -72,20 +80,41 @@ impl Component for Root { }) .collect::(); + let completed_achievements = state.achievements.iter().filter(|a| a.completed).count(); + + let mut milestones = state.milestones.clone(); + milestones.sort_by_key(|m| m.goal); + let milestones = milestones.into_iter().map(|m| html! { }).collect::(); + html! { <> + +

{"Polter A"}{"bend"}{"chievements!"}

+ +
+
+
+

{"Milestones"}

+
+ +
+ +
+
+ {milestones} + +

{"Achievements"}

+
- +
- -
- {achievements} + } } diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 5aa71b7..ea648e1 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -1,5 +1,6 @@ use components::admin::Admin; use components::create_achievement::CreateAchievementComponent; +use components::create_milestone::CreateMilestoneComponent; use components::root::Root; use std::rc::Rc; use yew::prelude::*; @@ -19,6 +20,8 @@ enum Route { Admin, #[at("/create-achievement")] CreateAchievement, + #[at("/create-milestone")] + CreateMilestone, #[not_found] #[at("/404")] NotFound, @@ -29,6 +32,7 @@ fn switch(selected_route: Route) -> Html { Route::Root => html! {}, Route::Admin => html! {}, Route::CreateAchievement => html! {}, + Route::CreateMilestone => html! {}, Route::NotFound => html! {

{"404 not found"}

}, } } diff --git a/crates/frontend/src/services/rest.rs b/crates/frontend/src/services/rest.rs index 1f5daef..058ae65 100644 --- a/crates/frontend/src/services/rest.rs +++ b/crates/frontend/src/services/rest.rs @@ -1,5 +1,7 @@ use common::CreateAchievement; +use common::CreateMilestone; use common::DeleteAchievement; +use common::DeleteMilestone; use common::ToggleAchievement; use reqwasm::http::Request; use serde::Serialize; @@ -58,11 +60,19 @@ impl RestService { Self::post_json(payload, "/toggle").await } + pub async fn create_achievement(payload: CreateAchievement) -> Result<(), RestServiceError> { + Self::post_json(payload, "/create").await + } + pub async fn delete_achievement(payload: DeleteAchievement) -> Result<(), RestServiceError> { Self::post_json(payload, "/delete").await } - pub async fn create_achievement(payload: CreateAchievement) -> Result<(), RestServiceError> { - Self::post_json(payload, "/create").await + pub async fn create_milestone(payload: CreateMilestone) -> Result<(), RestServiceError> { + Self::post_json(payload, "/create-milestone").await + } + + pub async fn delete_milestone(payload: DeleteMilestone) -> Result<(), RestServiceError> { + Self::post_json(payload, "/delete-milestone").await } }