RestService
This commit is contained in:
parent
ddd039fead
commit
53a5c8a976
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -234,6 +234,7 @@ dependencies = [
|
||||
"reqwasm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-logger",
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -11,9 +11,18 @@
|
||||
<link data-trunk rel="css" href="static/css/normalize.css">
|
||||
<link data-trunk rel="css" href="static/css/skeleton.css">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Own styles -->
|
||||
<link data-trunk rel="css" href="static/css/styles.css">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="static/images/favicon.png">
|
||||
|
||||
<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>
|
||||
<script>eruda.init();</script>
|
||||
</html>
|
||||
|
@ -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! {
|
||||
<div class="row">
|
||||
<div class="nine columns">
|
||||
<div class="row flex">
|
||||
<div class="flex-intrinsic-size">
|
||||
<button onclick={onclick_toggle} class={classes!(toggle_button_class)}><i class={classes!("fas", "fa-fw", completed.then_some("fa-star"), (!completed).then_some("fa-star-half-stroke"))} /></button>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<p>{goal}</p>
|
||||
</div>
|
||||
<div class="three columns">
|
||||
<button {onclick} class={button_class}>{"✓"}</button>
|
||||
<div class="flex-intrinsic-size">
|
||||
<button onclick={onclick_delete} class="button color-danger"><i class="fas fa-trash"/></button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
@ -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<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::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! {
|
||||
<>
|
||||
<div class="row">
|
||||
@ -127,7 +128,7 @@ impl Component for CreateAchievementComponent {
|
||||
<div class="row">
|
||||
<div class="twelve columns">
|
||||
<label for="achievementInput">{"New achievement"}</label>
|
||||
<input {oninput} value={input_value} class="u-full-width" type="text" id="achievementInput" />
|
||||
<input ref={self.input_ref.clone()} {oninput} value={self.input_value.clone()} class="u-full-width" type="text" id="achievementInput" />
|
||||
</div>
|
||||
</div>
|
||||
<input class="button-primary" type="submit" value="Submit" />
|
||||
|
@ -8,7 +8,7 @@ use yew_agent::Bridged;
|
||||
use yew_router::scope_ext::RouterScopeExt;
|
||||
|
||||
pub struct Root {
|
||||
wss: WebsocketService,
|
||||
_wss: WebsocketService,
|
||||
_producer: Box<dyn Bridge<EventBus>>,
|
||||
|
||||
/// 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<Self>) -> 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! {
|
||||
<>
|
||||
<h4>{"Achievements"}</h4>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{achievements}
|
||||
|
||||
<hr />
|
||||
<button onclick={onclick_create_achievement} class="button-primary">{"Create new achievement"}</button>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
11
crates/frontend/src/services/confirm.rs
Normal file
11
crates/frontend/src/services/confirm.rs
Normal file
@ -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`")
|
||||
}
|
||||
}
|
@ -1 +1,3 @@
|
||||
pub mod confirm;
|
||||
pub mod rest;
|
||||
pub mod websocket;
|
||||
|
68
crates/frontend/src/services/rest.rs
Normal file
68
crates/frontend/src/services/rest.rs
Normal file
@ -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<T: Serialize>(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
|
||||
}
|
||||
}
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
75
crates/frontend/static/css/styles.css
Normal file
75
crates/frontend/static/css/styles.css
Normal file
@ -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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user