From 53a5c8a97610ec9d9e5ce80d89ec7baf942dbc42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Juul=20Brunsh=C3=B8j?= Date: Mon, 12 Jun 2023 15:33:44 +0200 Subject: [PATCH] RestService --- Cargo.lock | 1 + crates/backend/src/main.rs | 2 +- crates/frontend/Cargo.toml | 1 + crates/frontend/index.html | 9 +++ crates/frontend/src/components/achievement.rs | 59 +++++++++------ .../src/components/create_achievement.rs | 39 +++++----- crates/frontend/src/components/root.rs | 18 +++-- crates/frontend/src/services/confirm.rs | 11 +++ crates/frontend/src/services/mod.rs | 2 + crates/frontend/src/services/rest.rs | 68 +++++++++++++++++ crates/frontend/src/services/websocket.rs | 6 +- crates/frontend/static/css/styles.css | 75 +++++++++++++++++++ 12 files changed, 239 insertions(+), 52 deletions(-) create mode 100644 crates/frontend/src/services/confirm.rs create mode 100644 crates/frontend/src/services/rest.rs create mode 100644 crates/frontend/static/css/styles.css diff --git a/Cargo.lock b/Cargo.lock index 64afb6d..cad306d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,6 +234,7 @@ dependencies = [ "reqwasm", "serde", "serde_json", + "thiserror", "wasm-bindgen", "wasm-bindgen-futures", "wasm-logger", diff --git a/crates/backend/src/main.rs b/crates/backend/src/main.rs index 0895398..8712085 100644 --- a/crates/backend/src/main.rs +++ b/crates/backend/src/main.rs @@ -103,7 +103,7 @@ async fn main() { ) .layer(CorsLayer::permissive()); - let addr = SocketAddr::from(([127, 0, 0, 1], 4000)); + let addr = SocketAddr::from(([0, 0, 0, 0], 4000)); tracing::debug!("listening on {}", addr); let server = axum::Server::bind(&addr) diff --git a/crates/frontend/Cargo.toml b/crates/frontend/Cargo.toml index a6ef6ed..e69f058 100644 --- a/crates/frontend/Cargo.toml +++ b/crates/frontend/Cargo.toml @@ -18,3 +18,4 @@ serde.workspace = true serde_json = "1.0.96" web-sys = "0.3.63" wasm-bindgen = "0.2.86" +thiserror = "1.0.40" diff --git a/crates/frontend/index.html b/crates/frontend/index.html index 79d9159..eb35b7b 100644 --- a/crates/frontend/index.html +++ b/crates/frontend/index.html @@ -11,9 +11,18 @@ + + + + + + + + + diff --git a/crates/frontend/src/components/achievement.rs b/crates/frontend/src/components/achievement.rs index 1a05880..12c93e6 100644 --- a/crates/frontend/src/components/achievement.rs +++ b/crates/frontend/src/components/achievement.rs @@ -1,9 +1,13 @@ +use crate::services::confirm::ConfirmService; +use crate::services::rest::RestService; use common::Achievement; +use common::DeleteAchievement; use common::ToggleAchievement; -use reqwasm::http::Request; use wasm_bindgen_futures::spawn_local; +use yew::classes; use yew::function_component; use yew::html; +use yew::use_memo; use yew::Callback; use yew::Html; use yew::Properties; @@ -21,39 +25,50 @@ pub fn AchievementComponent(props: &Props) -> Html { uuid, } = &props.achievement; + let confirm_service = use_memo(|_| ConfirmService, ()); + let uuid = *uuid; - let onclick = Callback::from(move |_| { + let onclick_toggle = Callback::from(move |_| { log::info!("button click, toggling achievement"); - - let payload = ToggleAchievement { uuid }; - let payload = serde_json::to_string(&payload).unwrap(); - spawn_local(async move { - let req = Request::post("http://127.0.0.1:4000/toggle") - .header("Content-Type", "application/json") - .body(payload); - let res = req.send().await; - match res { - Ok(response) => { - dbg!(response); - } - Err(err) => { - log::error!("Request failed: {err:?}"); - } + match RestService::toggle_achievement(ToggleAchievement { uuid }).await { + Ok(_response) => {} + Err(_err) => {} } }); }); - let button_class = if *completed { "button-primary" } else { "button" }; + let onclick_delete = Callback::from(move |_| { + if !confirm_service.confirm("Are you sure you want to delete?") { + return; + } + log::info!("Delete achievement confirmed."); + + spawn_local(async move { + match RestService::delete_achievement(DeleteAchievement { uuid }).await { + Ok(_response) => {} + Err(_err) => {} + } + }); + }); + + let toggle_button_class = if *completed { + "button-primary color-secondary" + } else { + "button" + }; html! { -
-
+
+
+ +
+

{goal}

-
- +
+
} diff --git a/crates/frontend/src/components/create_achievement.rs b/crates/frontend/src/components/create_achievement.rs index 1ec8283..05967a3 100644 --- a/crates/frontend/src/components/create_achievement.rs +++ b/crates/frontend/src/components/create_achievement.rs @@ -1,11 +1,13 @@ +use crate::services::rest::RestService; use common::CreateAchievement; -use reqwasm::http::Request; 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)] @@ -19,6 +21,7 @@ pub enum Msg { pub struct CreateAchievementComponent { input_value: String, submitted: bool, + input_ref: NodeRef, } impl Component for CreateAchievementComponent { type Message = Msg; @@ -28,6 +31,17 @@ impl Component for CreateAchievementComponent { 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::Reset => { @@ -36,25 +50,14 @@ impl Component for CreateAchievementComponent { true } Msg::Submit => { - log::info!("button click, creating achievement"); - + log::info!("Creating achievement"); let payload = CreateAchievement { goal: self.input_value.clone(), }; - let payload = serde_json::to_string(&payload).unwrap(); - spawn_local(async move { - let req = Request::post("http://127.0.0.1:4000/create") - .header("Content-Type", "application/json") - .body(payload); - let res = req.send().await; - match res { - Ok(response) => { - dbg!(response); - } - Err(err) => { - log::error!("Request failed: {err:?}"); - } + match RestService::create_achievement(payload).await { + Ok(_response) => {} + Err(_err) => {} } }); @@ -114,8 +117,6 @@ impl Component for CreateAchievementComponent { Msg::UpdateInput(input.value()) }); - let input_value = self.input_value.clone(); - html! { <>
@@ -127,7 +128,7 @@ impl Component for CreateAchievementComponent {
- +
diff --git a/crates/frontend/src/components/root.rs b/crates/frontend/src/components/root.rs index 31e4a63..b80bfcb 100644 --- a/crates/frontend/src/components/root.rs +++ b/crates/frontend/src/components/root.rs @@ -8,7 +8,7 @@ use yew_agent::Bridged; use yew_router::scope_ext::RouterScopeExt; pub struct Root { - wss: WebsocketService, + _wss: WebsocketService, _producer: Box>, /// None until the first websocket message is received. @@ -32,7 +32,7 @@ impl Component for Root { }; Self { - wss, + _wss: wss, _producer: EventBus::bridge(Rc::new(cb)), state: None, } @@ -49,7 +49,6 @@ impl Component for Root { } fn view(&self, ctx: &Context) -> Html { - let link = ctx.link().clone(); let nav = ctx.link().navigator().unwrap(); let onclick_create_achievement = Callback::from(move |_: web_sys::MouseEvent| { @@ -75,13 +74,18 @@ impl Component for Root { html! { <> -

{"Achievements"}

+
+
+

{"Achievements"}

+
+
+ +
+
+
{achievements} - -
- } } diff --git a/crates/frontend/src/services/confirm.rs b/crates/frontend/src/services/confirm.rs new file mode 100644 index 0000000..3751875 --- /dev/null +++ b/crates/frontend/src/services/confirm.rs @@ -0,0 +1,11 @@ +#[derive(Debug, PartialEq, Eq)] +pub struct ConfirmService; + +impl ConfirmService { + pub fn confirm(&self, message: &str) -> bool { + web_sys::window() + .expect("no global `window` exists") + .confirm_with_message(message) + .expect("failed to execute `window.confirm`") + } +} diff --git a/crates/frontend/src/services/mod.rs b/crates/frontend/src/services/mod.rs index 6eba44d..85fb67b 100644 --- a/crates/frontend/src/services/mod.rs +++ b/crates/frontend/src/services/mod.rs @@ -1 +1,3 @@ +pub mod confirm; +pub mod rest; pub mod websocket; diff --git a/crates/frontend/src/services/rest.rs b/crates/frontend/src/services/rest.rs new file mode 100644 index 0000000..1f5daef --- /dev/null +++ b/crates/frontend/src/services/rest.rs @@ -0,0 +1,68 @@ +use common::CreateAchievement; +use common::DeleteAchievement; +use common::ToggleAchievement; +use reqwasm::http::Request; +use serde::Serialize; + +pub struct RestService; + +#[derive(thiserror::Error, Debug)] +pub enum RestServiceError { + #[error("Failed to serialize/deserialize data: {0}")] + SerdeError(#[from] serde_json::Error), + + #[error("Unexpected response")] + UnexpectedResponse(reqwasm::http::Response), + + #[error("Failed request")] + ReqwasmError(#[from] reqwasm::Error), +} + +impl RestService { + fn hostname() -> String { + web_sys::window().unwrap().location().hostname().unwrap() + } + + fn url_to(endpoint: &str) -> String { + format!("http://{}:4000{endpoint}", Self::hostname()) + } + + async fn post_json(payload: T, path: &str) -> Result<(), RestServiceError> { + let req = Request::post(&Self::url_to(path)) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&payload)?); + + let res = req.send().await; + match res { + Ok(response) => { + let status_code = response.status(); + if (200..300).contains(&status_code) { + Ok(()) + } else { + log::warn!( + "Received unexpected status code: {}. Response: {:?}", + status_code, + response + ); + Err(RestServiceError::UnexpectedResponse(response)) + } + } + Err(err) => { + log::error!("Request failed: {:?}", err); + Err(err.into()) + } + } + } + + pub async fn toggle_achievement(payload: ToggleAchievement) -> Result<(), RestServiceError> { + Self::post_json(payload, "/toggle").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 + } +} diff --git a/crates/frontend/src/services/websocket.rs b/crates/frontend/src/services/websocket.rs index 3ff5564..78081a8 100644 --- a/crates/frontend/src/services/websocket.rs +++ b/crates/frontend/src/services/websocket.rs @@ -14,7 +14,9 @@ pub struct WebsocketService { impl WebsocketService { pub fn new() -> Self { - let ws = WebSocket::open("ws://127.0.0.1:4000/ws").unwrap(); + let hostname = web_sys::window().unwrap().location().hostname().unwrap(); + let ws_addr = format!("ws://{hostname}:4000/ws"); + let ws = WebSocket::open(&ws_addr).unwrap(); let (mut write, mut read) = ws.split(); @@ -32,13 +34,11 @@ impl WebsocketService { while let Some(msg) = read.next().await { match msg { Ok(Message::Text(data)) => { - log::debug!("from websocket: {}", data); event_bus.send(EventBusInput::EventBusMsg(data)); } Ok(Message::Bytes(b)) => { let decoded = std::str::from_utf8(&b); if let Ok(val) = decoded { - log::debug!("from websocket: {}", val); event_bus.send(EventBusInput::EventBusMsg(val.into())); } } diff --git a/crates/frontend/static/css/styles.css b/crates/frontend/static/css/styles.css new file mode 100644 index 0000000..a8f87ba --- /dev/null +++ b/crates/frontend/static/css/styles.css @@ -0,0 +1,75 @@ +:root { + --primary-color: #89CFF0; /* Baby Blue */ + --secondary-color: #FFB347; /* Pastel Orange */ + --accent-color: #77dd77; /* Pastel Green */ + --warning-color: #f1c40f; + --danger-color: #e74c3c; +} + +.button.color-primary { + color: var(--primary-color) !important; + border-color: var(--primary-color) !important; +} + +.button.color-secondary { + color: var(--secondary-color) !important; + border-color: var(--secondary-color) !important; +} + +.button.color-accent { + color: var(--accent-color) !important; + border-color: var(--accent-color) !important; +} + +.button.color-warning { + color: var(--warning-color) !important; + border-color: var(--warning-color) !important; +} + +.button.color-danger { + color: var(--danger-color) !important; + border-color: var(--danger-color) !important; +} + +.button-primary.color-primary { + background-color: var(--primary-color) !important; + border-color: var(--primary-color) !important; +} + +.button-primary.color-secondary { + background-color: var(--secondary-color) !important; + border-color: var(--secondary-color) !important; +} + +.button-primary.color-accent { + background-color: var(--accent-color) !important; + border-color: var(--accent-color) !important; +} + +.button-primary.color-warning { + background-color: var(--warning-color) !important; + border-color: var(--warning-color) !important; +} + +.button-primary.color-danger { + background-color: var(--danger-color) !important; + border-color: var(--danger-color) !important; +} + +.flex { + display: flex; + gap: 20px; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.flex-intrinsic-size { + flex-grow: 0; + flex-shrink: 0; +} + +.flex-grow { + flex-grow: 1; +}