milestones

This commit is contained in:
Asger Juul Brunshøj 2023-06-12 17:14:51 +02:00
parent 53a5c8a976
commit d786e6fd20
9 changed files with 292 additions and 14 deletions

View File

@ -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<SharedState>,
Json(create_milestone): Json<CreateMilestone>,
) -> 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<SharedState>,
Json(delete_milestone): Json<DeleteMilestone>,
) -> 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<SharedState>,
Json(toggle_achievement): Json<ToggleAchievement>,

View File

@ -6,6 +6,7 @@ pub type WebSocketMessage = State;
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct State {
pub achievements: Vec<Achievement>,
pub milestones: Vec<Milestone>,
}
#[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,
}

View File

@ -23,6 +23,8 @@
<link data-trunk rel="rust" href="Cargo.toml" data-bin="app" data-type="main" />
<link data-trunk rel="rust" href="Cargo.toml" data-bin="event_bus" data-type="worker" />
</head>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<body>
<script src="//cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
</body>
</html>

View File

@ -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 {
Self::default()
}
fn rendered(&mut self, _ctx: &yew::Context<Self>, first_render: bool) {
if first_render {
if let Some(input_element) = self.input_ref.get() {
let input_element = input_element
.dyn_into::<HtmlInputElement>()
.expect("Failed to cast input element");
input_element.focus().expect("Failed to focus input");
}
}
}
fn update(&mut self, _ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Submit => {
log::info!("Creating achievement");
let Ok(goal) = self.input_value.parse::<f64>() 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<Self>) -> 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! {
<>
<div class="row" style="margin-top: 15%">
<div class="twelve columns">
<h4>{"Submitted!"}</h4>
</div>
</div>
<div class="row">
<div class="six columns">
<button onclick={onclick_go_back} class="button">{"Go back"}</button>
</div>
</div>
</>
}
} 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::<web_sys::HtmlInputElement>().ok()) else { unreachable!() };
Msg::UpdateInput(input.value())
});
html! {
<>
<div class="row">
<button onclick={onclick_go_back} class="button">{"Back"}</button>
</div>
<form {onsubmit} >
<hr />
<div class="row">
<div class="twelve columns">
<label for="milestoneInput">{"New milestone"}</label>
<input ref={self.input_ref.clone()} {oninput} value={self.input_value.to_string()} class="u-full-width" type="number" id="milestoneInput" />
</div>
</div>
<input class="button-primary" type="submit" value="Submit" />
</form>
</>
}
}
}
}

View File

@ -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! {
<i class={classes!("fas", "fa-fw", "fa-star-half-stroke")} />
})
.take(unfilled)
.collect::<Html>();
let filled_stars = std::iter::repeat(html! {
<i style="color: var(--secondary-color)" class={classes!("fas", "fa-fw", "fa-star")} />
})
.take(filled)
.collect::<Html>();
html! {
<div class="row">
<p style="text-align: center" class="u-full-width">
{format!("{} / {}", props.completed_achievements, props.milestone.goal)}
<br />
{filled_stars}
{unfilled_stars}
</p>
</div>
}
}

View File

@ -1,4 +1,6 @@
pub mod achievement;
pub mod admin;
pub mod create_achievement;
pub mod create_milestone;
pub mod milestone;
pub mod root;

View File

@ -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<Self>) -> Html {
let nav = ctx.link().navigator().unwrap();
let onclick_create_achievement = Callback::from(move |_: web_sys::MouseEvent| {
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::<Html>();
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! { <MilestoneComponent milestone={m} completed_achievements={completed_achievements} /> }).collect::<Html>();
html! {
<>
<h1>{"Polter A"}<span style="color: var(--danger-color); text-decoration: line-through">{"bend"}</span>{"chievements!"}</h1>
<hr />
<div class="row flex flex-wrap">
<div class="flex-grow">
<h3>{"Milestones"}</h3>
</div>
<div class="flex-intrinsic-size">
<button onclick={onclick_create_milestone} class="button-primary">{"New"}</button>
</div>
</div>
{milestones}
<hr />
<div class="row flex flex-wrap">
<div class="flex-grow">
<h3>{"Achievements"}</h3>
</div>
<div class="flex-intrinsic-size">
<button onclick={onclick_create_achievement} class="button-primary">{"New achievement"}</button>
<button onclick={onclick_create_achievement} class="button-primary">{"New"}</button>
</div>
</div>
<hr />
{achievements}
</>
}
}

View File

@ -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! {<Root />},
Route::Admin => html! {<Admin/>},
Route::CreateAchievement => html! {<CreateAchievementComponent/>},
Route::CreateMilestone => html! {<CreateMilestoneComponent/>},
Route::NotFound => html! {<h1>{"404 not found"}</h1>},
}
}

View File

@ -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
}
}