diff --git a/Cargo.lock b/Cargo.lock index 415c904a..e59f3f11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3192,7 +3192,6 @@ dependencies = [ name = "nghe_frontend" version = "0.9.10" dependencies = [ - "anyhow", "codee", "concat-string", "console_error_panic_hook", @@ -3201,6 +3200,7 @@ dependencies = [ "leptos-use", "leptos_router", "nghe_api", + "thiserror 2.0.11", "uuid", "wasm-bindgen", "web-sys", diff --git a/nghe-api/src/user/info.rs b/nghe-api/src/user/get.rs similarity index 82% rename from nghe-api/src/user/info.rs rename to nghe-api/src/user/get.rs index bce0e810..48bb4a8e 100644 --- a/nghe-api/src/user/info.rs +++ b/nghe-api/src/user/get.rs @@ -3,7 +3,7 @@ use nghe_proc_macro::api_derive; use super::Role; #[api_derive(fake = true)] -#[endpoint(path = "userInfo", internal = true)] +#[endpoint(path = "getUser", internal = true)] pub struct Request; #[api_derive] diff --git a/nghe-api/src/user/mod.rs b/nghe-api/src/user/mod.rs index bd72bf32..6790a278 100644 --- a/nghe-api/src/user/mod.rs +++ b/nghe-api/src/user/mod.rs @@ -1,5 +1,5 @@ pub mod create; -pub mod info; +pub mod get; mod role; pub mod setup; diff --git a/nghe-backend/src/orm/users.rs b/nghe-backend/src/orm/users.rs index 5a6fa079..a1a1ae99 100644 --- a/nghe-backend/src/orm/users.rs +++ b/nghe-backend/src/orm/users.rs @@ -38,7 +38,7 @@ pub struct UsernameAuthentication<'a> { #[derive(Debug, Queryable, Selectable, Insertable, o2o)] #[diesel(table_name = users, check_for_backend(super::Type))] -#[owned_into(nghe_api::user::info::Response)] +#[owned_into(nghe_api::user::get::Response)] pub struct Info<'a> { #[into(~.into_owned())] pub username: Cow<'a, str>, diff --git a/nghe-backend/src/route/user/info.rs b/nghe-backend/src/route/user/get.rs similarity index 94% rename from nghe-backend/src/route/user/info.rs rename to nghe-backend/src/route/user/get.rs index 908a6526..070472a6 100644 --- a/nghe-backend/src/route/user/info.rs +++ b/nghe-backend/src/route/user/get.rs @@ -1,6 +1,6 @@ use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; use diesel_async::RunQueryDsl; -pub use nghe_api::user::info::{Request, Response}; +pub use nghe_api::user::get::{Request, Response}; use nghe_proc_macro::handler; use uuid::Uuid; diff --git a/nghe-backend/src/route/user/mod.rs b/nghe-backend/src/route/user/mod.rs index d548c238..7014e774 100644 --- a/nghe-backend/src/route/user/mod.rs +++ b/nghe-backend/src/route/user/mod.rs @@ -1,11 +1,11 @@ pub mod create; -mod info; +mod get; mod setup; nghe_proc_macro::build_router! { modules = [ create(internal = true), - info(internal = true), + get(internal = true), setup(internal = true), ], } diff --git a/nghe-frontend/Cargo.toml b/nghe-frontend/Cargo.toml index f97d5b9b..6bf54ebe 100644 --- a/nghe-frontend/Cargo.toml +++ b/nghe-frontend/Cargo.toml @@ -8,9 +8,9 @@ workspace = true [dependencies] concat-string = { workspace = true } +thiserror = { workspace = true } uuid = { workspace = true } -anyhow = { version = "1.0.95" } codee = { version = "0.2.0" } console_error_panic_hook = { version = "0.1.7" } gloo-net = { version = "0.6.0", default-features = false, features = [ diff --git a/nghe-frontend/src/client.rs b/nghe-frontend/src/client.rs index a47e3597..e7fb45fc 100644 --- a/nghe-frontend/src/client.rs +++ b/nghe-frontend/src/client.rs @@ -1,4 +1,3 @@ -use anyhow::Error; use codee::string::{FromToStringCodec, OptionCodec}; use concat_string::concat_string; use gloo_net::http; @@ -9,6 +8,8 @@ use leptos_use::storage::use_local_storage; use nghe_api::common::{JsonEndpoint, JsonURL}; use uuid::Uuid; +use crate::{Error, error}; + #[derive(Clone)] pub struct Client { authorization: String, @@ -57,11 +58,13 @@ impl Client { if response.ok() { Ok(response.json().await?) } else { + let code = response.status(); let text = response.text().await?; - if text.is_empty() { - anyhow::bail!("{} {}", response.status(), response.status_text()); - } - anyhow::bail!("{text}"); + Err(if text.is_empty() { + error::Http { code, text: response.status_text() }.into() + } else { + error::Http { code, text }.into() + }) } } diff --git a/nghe-frontend/src/components/authentication/login.rs b/nghe-frontend/src/components/authentication/login.rs index 2896ce46..6a7f86d2 100644 --- a/nghe-frontend/src/components/authentication/login.rs +++ b/nghe-frontend/src/components/authentication/login.rs @@ -27,13 +27,9 @@ pub fn Login() -> impl IntoView { let login_action = Action::<_, _, SyncStorage>::new_unsync(move |request: &Request| { let request = request.clone(); async move { - let api_key = Client::json_no_auth(&request) - .await - .map_err(|error| error.to_string())? - .api_key - .api_key; + let api_key = Client::json_no_auth(&request).await?.api_key.api_key; set_api_key(Some(api_key)); - Ok::<_, String>(()) + Ok(()) } }); diff --git a/nghe-frontend/src/components/authentication/setup.rs b/nghe-frontend/src/components/authentication/setup.rs index b3f73bb6..edbe8a97 100644 --- a/nghe-frontend/src/components/authentication/setup.rs +++ b/nghe-frontend/src/components/authentication/setup.rs @@ -27,8 +27,8 @@ pub fn Setup() -> impl IntoView { let setup_action = Action::<_, _, SyncStorage>::new_unsync(|request: &Request| { let request = request.clone(); async move { - Client::json_no_auth(&request).await.map_err(|error| error.to_string())?; - Ok::<_, String>(()) + Client::json_no_auth(&request).await?; + Ok(()) } }); Effect::new(move || { diff --git a/nghe-frontend/src/components/body.rs b/nghe-frontend/src/components/body.rs index a478f60a..c45e4d5a 100644 --- a/nghe-frontend/src/components/body.rs +++ b/nghe-frontend/src/components/body.rs @@ -3,52 +3,49 @@ use leptos::prelude::*; use leptos_router::components::{Route, Router, Routes}; use leptos_router::path; -use super::{Error, Home, Loading, Root, authentication}; +use super::{Home, Loading, Root, authentication}; pub fn Body() -> impl IntoView { - html::div().class("flex h-dvh box-border").child(( - Router( - component_props_builder(&Router) - .base("/frontend") - .children(ToChildren::to_children(move || { - Root(move || { - Routes( - component_props_builder(&Routes) - .fallback(|| "Not found") - .children(ToChildren::to_children(move || { - ( - Route( - component_props_builder(&Route) - .path(path!("")) - .view(Home) - .build(), - ), - Route( - component_props_builder(&Route) - .path(path!("/loading")) - .view(Loading) - .build(), - ), - Route( - component_props_builder(&Route) - .path(path!("/setup")) - .view(authentication::Setup) - .build(), - ), - Route( - component_props_builder(&Route) - .path(path!("/login")) - .view(authentication::Login) - .build(), - ), - ) - })) - .build(), - ) - }) - })) - .build(), - ), - Error(), + html::div().class("flex h-dvh box-border").child(Router( + component_props_builder(&Router) + .base("/frontend") + .children(ToChildren::to_children(move || { + Root(move || { + Routes( + component_props_builder(&Routes) + .fallback(|| "Not found") + .children(ToChildren::to_children(move || { + ( + Route( + component_props_builder(&Route) + .path(path!("")) + .view(Home) + .build(), + ), + Route( + component_props_builder(&Route) + .path(path!("/loading")) + .view(Loading) + .build(), + ), + Route( + component_props_builder(&Route) + .path(path!("/setup")) + .view(authentication::Setup) + .build(), + ), + Route( + component_props_builder(&Route) + .path(path!("/login")) + .view(authentication::Login) + .build(), + ), + ) + })) + .build(), + ) + }) + })) + .build(), )) } diff --git a/nghe-frontend/src/components/error/context.rs b/nghe-frontend/src/components/error/context.rs deleted file mode 100644 index b1b309cb..00000000 --- a/nghe-frontend/src/components/error/context.rs +++ /dev/null @@ -1,50 +0,0 @@ -use leptos::prelude::*; - -#[repr(transparent)] -#[derive(Debug, Clone, Default)] -pub struct PendingHide(pub bool); - -#[repr(transparent)] -#[derive(Debug, Clone, Default)] -pub struct Error(pub Option); - -impl PendingHide { - pub fn signal() -> ReadSignal { - let (read, write) = signal(Self::default()); - provide_context(write); - read - } - - fn context() -> WriteSignal { - use_context().expect("Toast error pending hide context should be provided") - } - - pub fn set() { - Self::context()(Self(true)); - } - - pub fn clear() { - Self::context()(Self(false)); - } -} - -impl Error { - pub fn signal() -> ReadSignal { - let (read, write) = signal(Self::default()); - provide_context(write); - read - } - - fn context() -> WriteSignal { - use_context().expect("Toast error context should be provided") - } - - pub fn set(inner: String) { - PendingHide::clear(); - Self::context()(Self(Some(inner))); - } - - pub fn clear() { - Self::context()(Self(None)); - } -} diff --git a/nghe-frontend/src/components/error/generic.rs b/nghe-frontend/src/components/error/generic.rs new file mode 100644 index 00000000..9af2241f --- /dev/null +++ b/nghe-frontend/src/components/error/generic.rs @@ -0,0 +1,21 @@ +use leptos::html::ElementChild; +use leptos::prelude::ClassAttribute; +use leptos::{IntoView, html}; + +pub fn Generic(error: String) -> impl IntoView { + html::section().class("h-full w-full bg-gray-50 dark:bg-gray-900").child( + html::div().class("py-20 px-8 mx-auto max-w-screen-xl lg:py-30 lg:px-12").child( + html::div().class("mx-auto max-w-screen-sm text-center").child(( + html::h1() + .class( + "mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-red-500 \ + dark:text-red-400", + ) + .child("Error"), + html::p() + .class("mb-4 text-2xl tracking-tight text-gray-900 md:text-3xl dark:text-white") + .child(error), + )), + ), + ) +} diff --git a/nghe-frontend/src/components/error/http.rs b/nghe-frontend/src/components/error/http.rs new file mode 100644 index 00000000..e3925f9c --- /dev/null +++ b/nghe-frontend/src/components/error/http.rs @@ -0,0 +1,23 @@ +use leptos::html::ElementChild; +use leptos::prelude::ClassAttribute; +use leptos::{IntoView, html}; + +use crate::error; + +pub fn Http(error: error::Http) -> impl IntoView { + html::section().class("h-full w-full bg-gray-50 dark:bg-gray-900").child( + html::div().class("py-20 px-8 mx-auto max-w-screen-xl lg:py-30 lg:px-12").child( + html::div().class("mx-auto max-w-screen-sm text-center").child(( + html::h1() + .class( + "mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-red-500 \ + dark:text-red-400", + ) + .child(error.code), + html::p() + .class("mb-4 text-2xl tracking-tight text-gray-900 md:text-3xl dark:text-white") + .child(error.text), + )), + ), + ) +} diff --git a/nghe-frontend/src/components/error/mod.rs b/nghe-frontend/src/components/error/mod.rs index f1081581..a86a692a 100644 --- a/nghe-frontend/src/components/error/mod.rs +++ b/nghe-frontend/src/components/error/mod.rs @@ -1,54 +1,23 @@ -mod context; -mod toast; +#![allow(clippy::needless_pass_by_value)] +mod generic; +mod http; + +use leptos::either::Either; use leptos::prelude::*; -use leptos::{ev, html}; -pub use toast::Toast; -pub fn Error() -> impl IntoView { - let pending_hide = context::PendingHide::signal(); - let error = context::Error::signal(); +fn Error(errors: ArcRwSignal) -> impl IntoView { + let errors = errors(); + leptos::logging::error!("{:?}", errors); + errors.into_iter().next().map(|(_, error)| { + let error = error.into_inner(); + match error.downcast_ref::().expect("Could not handle this error type") { + crate::Error::Http(error) => Either::Left(http::Http(error.clone())), + crate::Error::GlooNet(_) => Either::Right(generic::Generic(error.to_string())), + } + }) +} - let owner = Owner::current().expect("Owner should be provided"); - move || { - let owner = owner.clone(); - error().0.map(|error| { - html::div() - .role("alert") - .class(move || { - if pending_hide().0 { - "fixed p-4 text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 \ - dark:text-red-400 hover:ring-2 hover:ring-red-400 right-5 bottom-5 \ - max-w-full ml-5 md:ml-69 lg:ml-0 lg:max-w-2/4 transition-opacity \ - duration-300 ease-out opacity-0" - } else { - "fixed p-4 text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 \ - dark:text-red-400 hover:ring-2 hover:ring-red-400 right-5 bottom-5 \ - max-w-full ml-5 md:ml-69 lg:ml-0 lg:max-w-2/4 transition-opacity \ - duration-300 ease-out" - } - }) - .child( - html::div() - .class("flex items-center justify-center") - .child(html::div().class("text-sm font-medium text-justify").child(error)), - ) - .on(ev::click, move |_| { - if !pending_hide().0 { - let owner = owner.clone(); - owner.with(|| { - context::PendingHide::set(); - }); - set_timeout( - move || { - owner.with(|| { - context::Error::clear(); - }); - }, - std::time::Duration::from_millis(300), - ); - } - }) - }) - } +pub fn Boundary(child: TypedChildren) -> impl IntoView { + ErrorBoundary(component_props_builder(&ErrorBoundary).fallback(Error).children(child).build()) } diff --git a/nghe-frontend/src/components/error/toast.rs b/nghe-frontend/src/components/error/toast.rs deleted file mode 100644 index b6593d50..00000000 --- a/nghe-frontend/src/components/error/toast.rs +++ /dev/null @@ -1,25 +0,0 @@ -use super::context; - -pub trait Toast { - type Out; - - fn toast(self) -> Option; -} - -impl Toast for Result { - type Out = T; - - fn toast(self) -> Option { - match self { - Ok(t) => { - context::Error::clear(); - Some(t) - } - Err(e) => { - leptos::logging::error!("{:?}", e); - context::Error::set(e.to_string()); - None - } - } - } -} diff --git a/nghe-frontend/src/components/form/error.rs b/nghe-frontend/src/components/form/error.rs index 88cfefc3..51c54922 100644 --- a/nghe-frontend/src/components/form/error.rs +++ b/nghe-frontend/src/components/form/error.rs @@ -1,11 +1,14 @@ use leptos::html; use leptos::prelude::*; +use crate::Error; + pub fn Error( - action: Action, SyncStorage>, + action: Action, SyncStorage>, ) -> impl IntoView { move || { if let Some(Err(error)) = action.value().get() { + leptos::logging::error!("{:?}", error); Some( html::div() .role("submit-alert") @@ -13,7 +16,7 @@ pub fn Error( "p-4 text-red-800 border border-red-300 rounded-lg bg-red-50 \ dark:bg-gray-800 dark:text-red-400 dark:border-red-800", ) - .child(html::div().child(error)), + .child(error.to_string()), ) } else { None diff --git a/nghe-frontend/src/components/form/mod.rs b/nghe-frontend/src/components/form/mod.rs index 6f05ccfc..337c3f16 100644 --- a/nghe-frontend/src/components/form/mod.rs +++ b/nghe-frontend/src/components/form/mod.rs @@ -6,12 +6,14 @@ use leptos::html; use leptos::prelude::*; use web_sys::MouseEvent; +use crate::Error; + pub fn Form( title: &'static str, fields: impl Fn() -> IV, button: &'static str, on_click: impl Fn(MouseEvent) + 'static, - action: Action, SyncStorage>, + action: Action, SyncStorage>, ) -> impl IntoView { html::div() .class( diff --git a/nghe-frontend/src/components/form/submit.rs b/nghe-frontend/src/components/form/submit.rs index aa419bbd..036de1ce 100644 --- a/nghe-frontend/src/components/form/submit.rs +++ b/nghe-frontend/src/components/form/submit.rs @@ -3,10 +3,12 @@ use leptos::prelude::*; use leptos::{ev, html, svg}; use web_sys::MouseEvent; +use crate::Error; + pub fn Submit( button: &'static str, on_click: impl Fn(MouseEvent) + 'static, - action: Action, SyncStorage>, + action: Action, SyncStorage>, ) -> impl IntoView { let action_pending = move || action.pending().get(); html::button() diff --git a/nghe-frontend/src/components/mod.rs b/nghe-frontend/src/components/mod.rs index 550be370..e9641574 100644 --- a/nghe-frontend/src/components/mod.rs +++ b/nghe-frontend/src/components/mod.rs @@ -10,7 +10,7 @@ mod loading; mod root; pub use body::Body; -pub use error::Error; +pub use error::Boundary; pub use home::Home; pub use loading::Loading; pub use root::Root; diff --git a/nghe-frontend/src/components/root/navbar.rs b/nghe-frontend/src/components/root/navbar.rs index cc663c45..1db76aa9 100644 --- a/nghe-frontend/src/components/root/navbar.rs +++ b/nghe-frontend/src/components/root/navbar.rs @@ -1,8 +1,8 @@ use leptos::prelude::*; use leptos::{html, svg}; -use nghe_api::user::info::Response; +use nghe_api::user::get::Response; -pub fn Navbar(user_info: Response) -> impl IntoView { +pub fn Navbar(user: Response) -> impl IntoView { html::nav() .class( "bg-white border-b border-gray-200 px-4 py-2.5 dark:bg-gray-800 dark:border-gray-700 \ @@ -129,10 +129,10 @@ pub fn Navbar(user_info: Response) -> impl IntoView { .class( "block text-sm font-semibold text-gray-900 dark:text-white", ) - .child(user_info.username), + .child(user.username), html::span() .class("block text-sm text-gray-900 truncate dark:text-white") - .child(user_info.email), + .child(user.email), )), html::ul() .aria_labelledby("dropdown") diff --git a/nghe-frontend/src/components/root/shell.rs b/nghe-frontend/src/components/root/shell.rs index 11acf013..0e0c85ec 100644 --- a/nghe-frontend/src/components/root/shell.rs +++ b/nghe-frontend/src/components/root/shell.rs @@ -1,20 +1,19 @@ use leptos::html; use leptos::prelude::*; -use nghe_api::user::info::Request; +use nghe_api::user::get::Request; use super::navbar::Navbar; use super::sidebar::Sidebar; use crate::client::Client; -use crate::components::error::Toast as _; -use crate::components::{Loading, init}; +use crate::components::{Boundary, Loading, init}; pub fn Shell( child: impl Fn() -> IV + Copy + Send + Sync + 'static, ) -> impl IntoView { let (client, _) = Client::use_client_redirect(); - let user_info = LocalResource::new(move || async move { + let user = LocalResource::new(move || async move { let client = client().expect(Client::EXPECT_MSG); - client.json(&Request).await.toast() + client.json(&Request).await }); let node_ref = init::flowbite_suspense(); @@ -22,19 +21,21 @@ pub fn Shell( component_props_builder(&Suspense) .fallback(Loading) .children(ToChildren::to_children(move || { - Suspend::new(async move { - let user_info = user_info.await; - user_info.map(|user_info| { - html::div() - .node_ref(node_ref) - .class("antialiased bg-gray-50 dark:bg-gray-900 w-full") - .child(( - Navbar(user_info), - Sidebar(), - html::main().class("p-4 md:ml-64 pt-17 h-full").child(child()), - )) + Boundary(ToChildren::to_children(move || { + Suspend::new(async move { + user.await.map(|user| { + let role = user.role; + html::div() + .node_ref(node_ref) + .class("antialiased bg-gray-50 dark:bg-gray-900 w-full") + .child(( + Navbar(user), + Sidebar(role), + html::main().class("md:ml-64 pt-13 h-full").child(child()), + )) + }) }) - }) + })) })) .build(), ) diff --git a/nghe-frontend/src/components/root/sidebar.rs b/nghe-frontend/src/components/root/sidebar.rs index 445bf209..487b5568 100644 --- a/nghe-frontend/src/components/root/sidebar.rs +++ b/nghe-frontend/src/components/root/sidebar.rs @@ -1,7 +1,8 @@ -use leptos::html; use leptos::prelude::*; +use leptos::{html, svg}; +use nghe_api::user::Role; -pub fn Sidebar() -> impl IntoView { +pub fn Sidebar(user_role: Role) -> impl IntoView { html::aside() .id("drawer-navigation") .aria_label("Sidenav") @@ -11,8 +12,48 @@ pub fn Sidebar() -> impl IntoView { dark:border-gray-700", ) .child( - html::div() - .class("overflow-y-auto py-5 px-3 h-full bg-white dark:bg-gray-800") - .child(html::ul().class("space-y-2")), + html::div().class("overflow-y-auto py-5 px-3 h-full bg-white dark:bg-gray-800").child( + html::ul().class("space-y-2").child(if user_role.admin { + Some((html::li().child( + html::a() + .href("/frontend/users") + .class( + "flex items-center p-2 text-base font-medium text-gray-900 \ + rounded-lg dark:text-white hover:bg-gray-100 \ + dark:hover:bg-gray-700 group", + ) + .child(( + svg::svg() + .aria_hidden("true") + .attr("fill", "none") + .attr("viewBox", "0 0 24 24") + .attr("xmlns", "http://www.w3.org/2000/svg") + .class( + "w-7 h-7 text-gray-500 transition duration-75 \ + dark:text-gray-400 group-hover:text-gray-900 \ + dark:group-hover:text-white", + ) + .child( + svg::path() + .attr("stroke", "currentColor") + .attr("stroke-linecap", "round") + .attr("stroke-width", "2") + .attr( + "d", + "M4.5 17H4a1 1 0 0 1-1-1 3 3 0 0 1 \ + 3-3h1m0-3.05A2.5 2.5 0 1 1 9 5.5M19.5 17h.5a1 1 \ + 0 0 0 1-1 3 3 0 0 0-3-3h-1m0-3.05a2.5 2.5 0 1 \ + 0-2-4.45m.5 13.5h-7a1 1 0 0 1-1-1 3 3 0 0 1 \ + 3-3h3a3 3 0 0 1 3 3 1 1 0 0 1-1 1Zm-1-9.5a2.5 \ + 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Z", + ), + ), + html::span().class("ml-3").child("Users"), + )), + ),)) + } else { + None + }), + ), ) } diff --git a/nghe-frontend/src/error.rs b/nghe-frontend/src/error.rs new file mode 100644 index 00000000..d7b79b08 --- /dev/null +++ b/nghe-frontend/src/error.rs @@ -0,0 +1,22 @@ +use std::sync::Arc; + +#[derive(Debug, thiserror::Error, Clone)] +pub enum Error { + #[error(transparent)] + GlooNet(#[from] Arc), + #[error(transparent)] + Http(#[from] Http), +} + +#[derive(Debug, thiserror::Error, Clone)] +#[error("{code} {text}")] +pub struct Http { + pub code: u16, + pub text: String, +} + +impl From for Error { + fn from(value: gloo_net::Error) -> Self { + Arc::new(value).into() + } +} diff --git a/nghe-frontend/src/lib.rs b/nghe-frontend/src/lib.rs index 4de63888..3a7363ad 100644 --- a/nghe-frontend/src/lib.rs +++ b/nghe-frontend/src/lib.rs @@ -2,5 +2,7 @@ mod client; mod components; +mod error; pub use components::Body; +pub use error::Error; diff --git a/zellij/frontend.kdl b/zellij/frontend.kdl new file mode 100644 index 00000000..f442b962 --- /dev/null +++ b/zellij/frontend.kdl @@ -0,0 +1,17 @@ +layout { + tab split_direction="vertical" { + pane focus=true + pane split_direction="horizontal" { + pane { + cwd "nghe-frontend" + command "trunk" + args "serve" "--log" "INFO" + } + pane { + cwd "nghe-frontend" + command "npm" + args "run" "dev" + } + } + } +}