diff --git a/Cargo.lock b/Cargo.lock index 4e89bd5..0f4c1d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2641,6 +2641,7 @@ dependencies = [ "futures", "lettre", "oauth2", + "percent-encoding", "rand", "regex", "reqwest 0.12.14", diff --git a/Cargo.toml b/Cargo.toml index a5e8019..056668b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ dotenvy = "0.15.7" futures = "0.3.31" lettre = { version = "0.11.12", features = ["tokio1", "serde", "tracing", "tokio1-native-tls"] } oauth2 = "4.4.2" +percent-encoding = "2.3.1" rand = "0.8.5" regex = "1.11.1" reqwest = { version = "0.12.8", features = ["json"] } diff --git a/src/app_state.rs b/src/app_state.rs index 8b9f9b4..a92242b 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -11,6 +11,7 @@ use oauth2::basic::BasicClient; use crate::{ app_error::AppError, email::{Mailer, SmtpOptions}, + nav::NavbarBuilder, sessions::PgStore, settings::Settings, }; @@ -19,8 +20,9 @@ use crate::{ pub struct App { pub db_pool: Pool, pub mailer: Mailer, - pub reqwest_client: reqwest::Client, + pub navbar_template: NavbarBuilder, pub oauth_client: BasicClient, + pub reqwest_client: reqwest::Client, pub session_store: PgStore, pub settings: Settings, } @@ -53,6 +55,7 @@ impl App { Ok(Self { db_pool, mailer, + navbar_template: NavbarBuilder::default().with_base_path(&settings.base_path), oauth_client, reqwest_client, session_store, diff --git a/src/channels_router.rs b/src/channels_router.rs index b4c630f..0dc2975 100644 --- a/src/channels_router.rs +++ b/src/channels_router.rs @@ -20,7 +20,7 @@ use crate::{ csrf::generate_csrf_token, email::{MailSender as _, Mailer}, guards, - nav_state::{Breadcrumb, NavState}, + nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_CHANNELS}, schema::channels, settings::Settings, users::CurrentUser, @@ -69,6 +69,7 @@ pub fn new_router() -> Router { async fn channels_page( State(Settings { base_path, .. }): State, + State(navbar_template): State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, @@ -88,28 +89,29 @@ async fn channels_page( }; let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; - let nav_state = NavState::new() - .set_base_path(&base_path) - .push_team(&team) - .push_slug(Breadcrumb { - href: "channels".to_string(), - label: "Channels".to_string(), - }) - .set_navbar_active_item("channels"); #[derive(Template)] #[template(path = "channels.html")] struct ResponseTemplate { base_path: String, + breadcrumbs: BreadcrumbTrail, channels: Vec, csrf_token: String, - nav_state: NavState, + navbar: Navbar, } Ok(Html( ResponseTemplate { + breadcrumbs: BreadcrumbTrail::from_base_path(&base_path) + .with_i18n_slug("en") + .push_slug("Teams", "teams") + .push_slug(&team.name, &team.id.simple().to_string()) + .push_slug("Channels", "channels"), base_path, channels, csrf_token, - nav_state, + navbar: navbar_template + .with_param("team_id", &team.id.simple().to_string()) + .with_active_item(NAVBAR_ITEM_CHANNELS) + .build(), } .render()?, ) @@ -167,6 +169,7 @@ async fn post_new_channel( async fn channel_page( State(Settings { base_path, .. }): State, + State(navbar_template): State, DbConn(db_conn): DbConn, Path((team_id, channel_id)): Path<(Uuid, Uuid)>, CurrentUser(current_user): CurrentUser, @@ -195,18 +198,6 @@ async fn channel_page( }; let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; - let nav_state = NavState::new() - .set_base_path(&base_path) - .push_team(&team) - .push_slug(Breadcrumb { - href: "channels".to_string(), - label: "Channels".to_string(), - }) - .push_slug(Breadcrumb { - href: channel.id.simple().to_string(), - label: channel.name.clone(), - }) - .set_navbar_active_item("channels"); match channel.backend_config { BackendConfig::Email(_) => { @@ -214,16 +205,26 @@ async fn channel_page( #[template(path = "channel-email.html")] struct ResponseTemplate { base_path: String, + breadcrumbs: BreadcrumbTrail, channel: Channel, csrf_token: String, - nav_state: NavState, + navbar: Navbar, } Ok(Html( ResponseTemplate { + breadcrumbs: BreadcrumbTrail::from_base_path(&base_path) + .with_i18n_slug("en") + .push_slug("Teams", "teams") + .push_slug(&team.name, &team.id.simple().to_string()) + .push_slug("Channels", "channels") + .push_slug(&channel.name, &channel.id.simple().to_string()), base_path, channel, csrf_token, - nav_state, + navbar: navbar_template + .with_param("team_id", &team.id.simple().to_string()) + .with_active_item(NAVBAR_ITEM_CHANNELS) + .build(), } .render()?, )) diff --git a/src/main.rs b/src/main.rs index e003e2f..ab6b060 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ mod guards; mod messages; mod middleware; mod migrations; -mod nav_state; +mod nav; mod projects; mod projects_router; mod router; diff --git a/src/nav.rs b/src/nav.rs new file mode 100644 index 0000000..0409071 --- /dev/null +++ b/src/nav.rs @@ -0,0 +1,230 @@ +use std::collections::HashMap; + +use axum::extract::FromRef; + +use crate::app_state::AppState; + +pub const NAVBAR_ITEM_TEAMS: &str = "teams"; +pub const NAVBAR_ITEM_PROJECTS: &str = "projects"; +pub const NAVBAR_ITEM_CHANNELS: &str = "channels"; + +#[derive(Clone, Debug)] +pub struct BreadcrumbTrail { + base_path: String, + breadcrumbs: Vec, +} + +impl BreadcrumbTrail { + /// Initialize with a non-empty base path. + pub fn from_base_path(base_path: &str) -> Self { + Self { + base_path: base_path.to_owned(), + breadcrumbs: Vec::new(), + } + } + + /// Append an i18n path segment to the base path. + pub fn with_i18n_slug(mut self, language_code: &str) -> Self { + self.base_path.push('/'); + self.base_path.push_str(language_code); + self + } + + /// Add a breadcrumb by name and slug. If other breadcrumbs have already + /// been added, href will be generated by appending it to the previous href + /// as "/". Otherwise, it will be appended to the base path + /// with i18n slug (if any). + pub fn push_slug(mut self, label: &str, slug: &str) -> Self { + let href = if let Some(prev_breadcrumb) = self.iter().last() { + format!( + "{}/{}", + prev_breadcrumb.href, + percent_encoding::percent_encode( + slug.as_bytes(), + percent_encoding::NON_ALPHANUMERIC + ) + ) + } else { + format!("{}/{}", self.base_path, slug) + }; + self.breadcrumbs.push(Breadcrumb { + label: label.to_owned(), + href, + }); + self + } + + pub fn iter(&self) -> std::slice::Iter<'_, Breadcrumb> { + self.breadcrumbs.iter() + } + + /// Get an absolute URI path, starting from the child of the last + /// breadcrumb. For example, if the last breadcrumb has an href of + /// "/en/teams/team123" and the relative path is "../team456", the result + /// will be "/en/teams/team456". If no breadcrumbs exist, the base path + /// with i18n slug (if any) will be used. + pub fn join(&self, rel_path: &str) -> String { + let base = if let Some(breadcrumb) = self.iter().last() { + &breadcrumb.href + } else { + &self.base_path + }; + let mut path_buf: Vec<&str> = base.split('/').collect(); + for rel_segment in rel_path.split('/') { + if rel_segment == "." { + continue; + } else if rel_segment == ".." { + path_buf.pop(); + } else { + path_buf.push(rel_segment); + } + } + path_buf.join("/") + } +} + +impl IntoIterator for BreadcrumbTrail { + type Item = Breadcrumb; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.breadcrumbs.into_iter() + } +} + +#[derive(Clone, Debug)] +pub struct Breadcrumb { + pub href: String, + pub label: String, +} + +#[derive(Clone, Debug)] +pub struct NavbarBuilder { + base_path: String, + items: Vec, + active_item: Option, + params: HashMap, +} + +impl NavbarBuilder { + pub fn new() -> Self { + Self { + base_path: "".to_owned(), + items: Vec::new(), + active_item: None, + params: HashMap::new(), + } + } + + pub fn with_base_path(mut self, base_path: &str) -> Self { + self.base_path = base_path.to_owned(); + self + } + + /// Add a navbar item. Subpath is a path relative to the base path, and it + /// may contain placeholders for path params, such as "/{lang}/teams". + /// The navbar item will only be displayed if all corresponding path params + /// are registered using .with_param(). + pub fn push_item(mut self, id: &str, label: &str, subpath: &str) -> Self { + self.items.push(NavbarItem { + id: id.to_owned(), + href: subpath.to_owned(), + label: label.to_owned(), + }); + self + } + + /// Registers a path param with the navbar builder. + pub fn with_param(mut self, k: &str, v: &str) -> Self { + self.params.insert(k.to_owned(), v.to_owned()); + self + } + + /// If a visible navbar item matches the provided ID, it will render as + /// active. Calling this method overrides any previously specified value. + pub fn with_active_item(mut self, item_id: &str) -> Self { + self.active_item = Some(item_id.to_owned()); + self + } + + pub fn build(self) -> Navbar { + let mut built_items: Vec = Vec::with_capacity(self.items.len()); + for item in self.items { + let path_segments = item.href.split('/'); + let substituted_segments: Vec> = path_segments + .map(|segment| { + if segment.starts_with("{") && segment.ends_with("}") { + let param_k = segment[1..segment.len() - 1].trim(); + self.params.get(param_k).map(|v| v.as_str()) + } else { + Some(segment) + } + }) + .collect(); + if substituted_segments.iter().all(|segment| segment.is_some()) { + built_items.push(NavbarItem { + id: item.id, + href: format!( + "{}{}", + self.base_path, + substituted_segments + .into_iter() + .map(|segment| { + segment.expect( + "should already have checked that all path segments are Some", + ) + }) + .collect::>() + .join("/") + ), + label: item.label, + }); + } + } + Navbar { + active_item: self.active_item, + items: built_items, + } + } +} + +impl Default for NavbarBuilder { + fn default() -> Self { + Self::new() + .push_item(NAVBAR_ITEM_TEAMS, "Teams", "/en/teams") + .push_item( + NAVBAR_ITEM_PROJECTS, + "Projects", + "/en/teams/{team_id}/projects", + ) + .push_item( + NAVBAR_ITEM_CHANNELS, + "Channels", + "/en/teams/{team_id}/channels", + ) + } +} + +impl FromRef for NavbarBuilder +where + S: Into + Clone, +{ + fn from_ref(state: &S) -> Self { + Into::::into(state.clone()) + .navbar_template + .clone() + } +} + +#[derive(Clone, Debug)] +pub struct Navbar { + pub items: Vec, + pub active_item: Option, +} + +#[derive(Clone, Debug)] +pub struct NavbarItem { + pub href: String, + pub id: String, + pub label: String, +} diff --git a/src/nav_state.rs b/src/nav_state.rs deleted file mode 100644 index e957929..0000000 --- a/src/nav_state.rs +++ /dev/null @@ -1,88 +0,0 @@ -use uuid::Uuid; - -use crate::{projects::Project, teams::Team}; - -#[derive(Clone, Debug, Default)] -pub struct Breadcrumb { - pub href: String, - pub label: String, -} - -// TODO: This is a very quick, dirty, and awkward approach to storing -// navigation state. It can and should be scrapped and replaced when time -// allows. - -#[derive(Clone, Debug, Default)] -pub struct NavState { - pub base_path: String, - pub breadcrumbs: Vec, - pub team_id: Option, - pub navbar_active_item: String, -} - -impl NavState { - pub fn new() -> Self { - Self::default() - } - - pub fn set_base_path(mut self, base_path: &str) -> Self { - self.base_path = base_path.to_string(); - self - } - - pub fn push_team(mut self, team: &Team) -> Self { - self.team_id = Some(team.id); - self.navbar_active_item = "teams".to_string(); - self.breadcrumbs.push(Breadcrumb { - href: format!("{}/en/teams", self.base_path), - label: "Teams".to_string(), - }); - self.breadcrumbs.push(Breadcrumb { - href: format!("{}/en/teams/{}", self.base_path, team.id.simple()), - label: team.name.clone(), - }); - self - } - - pub fn push_project(mut self, project: &Project) -> Result { - let team_id = self.team_id.ok_or(anyhow::anyhow!( - "NavState.push_project() called out of order" - ))?; - self.navbar_active_item = "projects".to_string(); - self.breadcrumbs.push(Breadcrumb { - href: format!("{}/en/teams/{}/projects", self.base_path, team_id), - label: "Projects".to_string(), - }); - self.breadcrumbs.push(Breadcrumb { - href: format!( - "{}/en/teams/{}/projects/{}", - self.base_path, - team_id, - project.id.simple() - ), - label: project.name.clone(), - }); - Ok(self) - } - - /// Add a breadcrumb with an href treated as a child of the previous - /// breadcrumb's path (or of the base_path if no breadcrumbs exist). - pub fn push_slug(mut self, breadcrumb: Breadcrumb) -> Self { - let starting_path = self - .breadcrumbs - .iter() - .last() - .map(|breadcrumb| breadcrumb.href.clone()) - .unwrap_or(self.base_path.clone()); - self.breadcrumbs.push(Breadcrumb { - href: format!("{}/{}", starting_path, breadcrumb.href), - label: breadcrumb.label, - }); - self - } - - pub fn set_navbar_active_item(mut self, value: &str) -> Self { - self.navbar_active_item = value.to_string(); - self - } -} diff --git a/src/projects_router.rs b/src/projects_router.rs index 704e80a..b62b132 100644 --- a/src/projects_router.rs +++ b/src/projects_router.rs @@ -21,7 +21,7 @@ use crate::{ channels::Channel, csrf::generate_csrf_token, guards, - nav_state::{Breadcrumb, NavState}, + nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_PROJECTS}, projects::Project, schema::channel_selections, settings::Settings, @@ -40,6 +40,7 @@ pub fn new_router() -> Router { async fn projects_page( State(Settings { base_path, .. }): State, + State(navbar_template): State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, @@ -79,25 +80,26 @@ async fn projects_page( #[template(path = "projects.html")] struct ResponseTemplate { base_path: String, + breadcrumbs: BreadcrumbTrail, csrf_token: String, keys: Vec, - nav_state: NavState, + navbar: Navbar, projects: Vec, } let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; - let nav_state = NavState::new() - .set_base_path(&base_path) - .push_team(&team) - .push_slug(Breadcrumb { - href: "projects".to_string(), - label: "Projects".to_string(), - }) - .set_navbar_active_item("projects"); Ok(Html( ResponseTemplate { + breadcrumbs: BreadcrumbTrail::from_base_path(&base_path) + .with_i18n_slug("en") + .push_slug("Teams", "teams") + .push_slug(&team.name, &team.id.simple().to_string()) + .push_slug("Projects", "projects"), base_path, csrf_token, - nav_state, + navbar: navbar_template + .with_param("team_id", &team.id.simple().to_string()) + .with_active_item(NAVBAR_ITEM_PROJECTS) + .build(), projects, keys: api_keys, } @@ -108,6 +110,7 @@ async fn projects_page( async fn project_page( State(Settings { base_path, .. }): State, + State(navbar_template): State, DbConn(db_conn): DbConn, Path((team_id, project_id)): Path<(Uuid, Uuid)>, CurrentUser(current_user): CurrentUser, @@ -153,28 +156,33 @@ async fn project_page( .context("failed to load team channels")?; let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; - let nav_state = NavState::new() - .set_base_path(&base_path) - .push_team(&team) - .push_project(&project)?; - #[derive(Template)] #[template(path = "project.html")] struct ResponseTemplate { base_path: String, + breadcrumbs: BreadcrumbTrail, csrf_token: String, enabled_channel_ids: HashSet, - nav_state: NavState, + navbar: Navbar, project: Project, team_channels: Vec, } Ok(Html( ResponseTemplate { + breadcrumbs: BreadcrumbTrail::from_base_path(&base_path) + .with_i18n_slug("en") + .push_slug("Teams", "teams") + .push_slug(&team.name, &team.id.simple().to_string()) + .push_slug("Projects", "projects") + .push_slug(&project.name, &project.id.simple().to_string()), base_path, csrf_token, enabled_channel_ids, project, - nav_state, + navbar: navbar_template + .with_param("team_id", &team.id.simple().to_string()) + .with_active_item(NAVBAR_ITEM_PROJECTS) + .build(), team_channels, } .render()?, diff --git a/src/teams_router.rs b/src/teams_router.rs index 9845dcb..74feff4 100644 --- a/src/teams_router.rs +++ b/src/teams_router.rs @@ -17,7 +17,7 @@ use crate::{ app_state::{AppState, DbConn}, csrf::generate_csrf_token, guards, - nav_state::{Breadcrumb, NavState}, + nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS}, projects::{Project, DEFAULT_PROJECT_NAME}, schema::{team_memberships, teams}, settings::Settings, @@ -37,6 +37,7 @@ pub fn new_router() -> Router { async fn teams_page( State(Settings { base_path, .. }): State, + State(navbar_template): State, DbConn(conn): DbConn, CurrentUser(current_user): CurrentUser, ) -> Result { @@ -48,24 +49,21 @@ async fn teams_page( .context("failed to load team memberships") .map(|memberships| memberships.into_iter().map(|(_, team)| team).collect())? }; - let nav_state = NavState::new() - .set_base_path(&base_path) - .push_slug(Breadcrumb { - href: "en/teams".to_string(), - label: "Teams".to_string(), - }) - .set_navbar_active_item("teams"); #[derive(Template)] #[template(path = "teams.html")] struct ResponseTemplate { base_path: String, + breadcrumbs: BreadcrumbTrail, + navbar: Navbar, teams: Vec, - nav_state: NavState, } Ok(Html( ResponseTemplate { + breadcrumbs: BreadcrumbTrail::from_base_path(&base_path) + .with_i18n_slug("en") + .push_slug("Teams", "teams"), base_path, - nav_state, + navbar: navbar_template.with_active_item(NAVBAR_ITEM_TEAMS).build(), teams, } .render()?, @@ -105,30 +103,28 @@ async fn post_new_api_key( async fn new_team_page( State(Settings { base_path, .. }): State, + State(navbar_template): State, DbConn(db_conn): DbConn, CurrentUser(current_user): CurrentUser, ) -> Result { let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; - let nav_state = NavState::new() - .set_base_path(&base_path) - .push_slug(Breadcrumb { - href: "en/new-team".to_string(), - label: "New Team".to_string(), - }) - .set_navbar_active_item("teams"); #[derive(Template)] #[template(path = "new-team.html")] struct ResponseTemplate { base_path: String, + breadcrumbs: BreadcrumbTrail, csrf_token: String, - nav_state: NavState, + navbar: Navbar, } Ok(Html( ResponseTemplate { + breadcrumbs: BreadcrumbTrail::from_base_path(&base_path) + .with_i18n_slug("en") + .push_slug("New Team", "new-team"), base_path, csrf_token, - nav_state, + navbar: navbar_template.with_active_item(NAVBAR_ITEM_TEAMS).build(), } .render()?, )) diff --git a/templates/breadcrumbs.html b/templates/breadcrumbs.html index c34ecb1..854eb74 100644 --- a/templates/breadcrumbs.html +++ b/templates/breadcrumbs.html @@ -1,9 +1,9 @@