1
0
Fork 0
forked from 2sys/shoutdotdev

refactor nav state data structures

This commit is contained in:
Brent Schroeter 2025-04-04 13:42:10 -07:00
parent 29960e0b4e
commit bef9cc4cca
15 changed files with 323 additions and 189 deletions

1
Cargo.lock generated
View file

@ -2641,6 +2641,7 @@ dependencies = [
"futures", "futures",
"lettre", "lettre",
"oauth2", "oauth2",
"percent-encoding",
"rand", "rand",
"regex", "regex",
"reqwest 0.12.14", "reqwest 0.12.14",

View file

@ -20,6 +20,7 @@ dotenvy = "0.15.7"
futures = "0.3.31" futures = "0.3.31"
lettre = { version = "0.11.12", features = ["tokio1", "serde", "tracing", "tokio1-native-tls"] } lettre = { version = "0.11.12", features = ["tokio1", "serde", "tracing", "tokio1-native-tls"] }
oauth2 = "4.4.2" oauth2 = "4.4.2"
percent-encoding = "2.3.1"
rand = "0.8.5" rand = "0.8.5"
regex = "1.11.1" regex = "1.11.1"
reqwest = { version = "0.12.8", features = ["json"] } reqwest = { version = "0.12.8", features = ["json"] }

View file

@ -11,6 +11,7 @@ use oauth2::basic::BasicClient;
use crate::{ use crate::{
app_error::AppError, app_error::AppError,
email::{Mailer, SmtpOptions}, email::{Mailer, SmtpOptions},
nav::NavbarBuilder,
sessions::PgStore, sessions::PgStore,
settings::Settings, settings::Settings,
}; };
@ -19,8 +20,9 @@ use crate::{
pub struct App { pub struct App {
pub db_pool: Pool, pub db_pool: Pool,
pub mailer: Mailer, pub mailer: Mailer,
pub reqwest_client: reqwest::Client, pub navbar_template: NavbarBuilder,
pub oauth_client: BasicClient, pub oauth_client: BasicClient,
pub reqwest_client: reqwest::Client,
pub session_store: PgStore, pub session_store: PgStore,
pub settings: Settings, pub settings: Settings,
} }
@ -53,6 +55,7 @@ impl App {
Ok(Self { Ok(Self {
db_pool, db_pool,
mailer, mailer,
navbar_template: NavbarBuilder::default().with_base_path(&settings.base_path),
oauth_client, oauth_client,
reqwest_client, reqwest_client,
session_store, session_store,

View file

@ -20,7 +20,7 @@ use crate::{
csrf::generate_csrf_token, csrf::generate_csrf_token,
email::{MailSender as _, Mailer}, email::{MailSender as _, Mailer},
guards, guards,
nav_state::{Breadcrumb, NavState}, nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_CHANNELS},
schema::channels, schema::channels,
settings::Settings, settings::Settings,
users::CurrentUser, users::CurrentUser,
@ -69,6 +69,7 @@ pub fn new_router() -> Router<AppState> {
async fn channels_page( async fn channels_page(
State(Settings { base_path, .. }): State<Settings>, State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn, DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>, Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser, 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 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)] #[derive(Template)]
#[template(path = "channels.html")] #[template(path = "channels.html")]
struct ResponseTemplate { struct ResponseTemplate {
base_path: String, base_path: String,
breadcrumbs: BreadcrumbTrail,
channels: Vec<Channel>, channels: Vec<Channel>,
csrf_token: String, csrf_token: String,
nav_state: NavState, navbar: Navbar,
} }
Ok(Html( Ok(Html(
ResponseTemplate { 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, base_path,
channels, channels,
csrf_token, csrf_token,
nav_state, navbar: navbar_template
.with_param("team_id", &team.id.simple().to_string())
.with_active_item(NAVBAR_ITEM_CHANNELS)
.build(),
} }
.render()?, .render()?,
) )
@ -167,6 +169,7 @@ async fn post_new_channel(
async fn channel_page( async fn channel_page(
State(Settings { base_path, .. }): State<Settings>, State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn, DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>, Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser, 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 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 { match channel.backend_config {
BackendConfig::Email(_) => { BackendConfig::Email(_) => {
@ -214,16 +205,26 @@ async fn channel_page(
#[template(path = "channel-email.html")] #[template(path = "channel-email.html")]
struct ResponseTemplate { struct ResponseTemplate {
base_path: String, base_path: String,
breadcrumbs: BreadcrumbTrail,
channel: Channel, channel: Channel,
csrf_token: String, csrf_token: String,
nav_state: NavState, navbar: Navbar,
} }
Ok(Html( Ok(Html(
ResponseTemplate { 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, base_path,
channel, channel,
csrf_token, csrf_token,
nav_state, navbar: navbar_template
.with_param("team_id", &team.id.simple().to_string())
.with_active_item(NAVBAR_ITEM_CHANNELS)
.build(),
} }
.render()?, .render()?,
)) ))

View file

@ -25,7 +25,7 @@ mod guards;
mod messages; mod messages;
mod middleware; mod middleware;
mod migrations; mod migrations;
mod nav_state; mod nav;
mod projects; mod projects;
mod projects_router; mod projects_router;
mod router; mod router;

230
src/nav.rs Normal file
View file

@ -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<Breadcrumb>,
}
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 "<previous>/<slug>". 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<Breadcrumb>;
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<NavbarItem>,
active_item: Option<String>,
params: HashMap<String, String>,
}
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<NavbarItem> = Vec::with_capacity(self.items.len());
for item in self.items {
let path_segments = item.href.split('/');
let substituted_segments: Vec<Option<&str>> = 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::<Vec<_>>()
.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<S> FromRef<S> for NavbarBuilder
where
S: Into<AppState> + Clone,
{
fn from_ref(state: &S) -> Self {
Into::<AppState>::into(state.clone())
.navbar_template
.clone()
}
}
#[derive(Clone, Debug)]
pub struct Navbar {
pub items: Vec<NavbarItem>,
pub active_item: Option<String>,
}
#[derive(Clone, Debug)]
pub struct NavbarItem {
pub href: String,
pub id: String,
pub label: String,
}

View file

@ -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<Breadcrumb>,
pub team_id: Option<Uuid>,
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<Self, anyhow::Error> {
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
}
}

View file

@ -21,7 +21,7 @@ use crate::{
channels::Channel, channels::Channel,
csrf::generate_csrf_token, csrf::generate_csrf_token,
guards, guards,
nav_state::{Breadcrumb, NavState}, nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_PROJECTS},
projects::Project, projects::Project,
schema::channel_selections, schema::channel_selections,
settings::Settings, settings::Settings,
@ -40,6 +40,7 @@ pub fn new_router() -> Router<AppState> {
async fn projects_page( async fn projects_page(
State(Settings { base_path, .. }): State<Settings>, State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn, DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>, Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser, CurrentUser(current_user): CurrentUser,
@ -79,25 +80,26 @@ async fn projects_page(
#[template(path = "projects.html")] #[template(path = "projects.html")]
struct ResponseTemplate { struct ResponseTemplate {
base_path: String, base_path: String,
breadcrumbs: BreadcrumbTrail,
csrf_token: String, csrf_token: String,
keys: Vec<ApiKey>, keys: Vec<ApiKey>,
nav_state: NavState, navbar: Navbar,
projects: Vec<Project>, projects: Vec<Project>,
} }
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; 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( Ok(Html(
ResponseTemplate { 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, base_path,
csrf_token, csrf_token,
nav_state, navbar: navbar_template
.with_param("team_id", &team.id.simple().to_string())
.with_active_item(NAVBAR_ITEM_PROJECTS)
.build(),
projects, projects,
keys: api_keys, keys: api_keys,
} }
@ -108,6 +110,7 @@ async fn projects_page(
async fn project_page( async fn project_page(
State(Settings { base_path, .. }): State<Settings>, State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn, DbConn(db_conn): DbConn,
Path((team_id, project_id)): Path<(Uuid, Uuid)>, Path((team_id, project_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser, CurrentUser(current_user): CurrentUser,
@ -153,28 +156,33 @@ async fn project_page(
.context("failed to load team channels")?; .context("failed to load team channels")?;
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; 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)] #[derive(Template)]
#[template(path = "project.html")] #[template(path = "project.html")]
struct ResponseTemplate { struct ResponseTemplate {
base_path: String, base_path: String,
breadcrumbs: BreadcrumbTrail,
csrf_token: String, csrf_token: String,
enabled_channel_ids: HashSet<Uuid>, enabled_channel_ids: HashSet<Uuid>,
nav_state: NavState, navbar: Navbar,
project: Project, project: Project,
team_channels: Vec<Channel>, team_channels: Vec<Channel>,
} }
Ok(Html( Ok(Html(
ResponseTemplate { 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, base_path,
csrf_token, csrf_token,
enabled_channel_ids, enabled_channel_ids,
project, project,
nav_state, navbar: navbar_template
.with_param("team_id", &team.id.simple().to_string())
.with_active_item(NAVBAR_ITEM_PROJECTS)
.build(),
team_channels, team_channels,
} }
.render()?, .render()?,

View file

@ -17,7 +17,7 @@ use crate::{
app_state::{AppState, DbConn}, app_state::{AppState, DbConn},
csrf::generate_csrf_token, csrf::generate_csrf_token,
guards, guards,
nav_state::{Breadcrumb, NavState}, nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS},
projects::{Project, DEFAULT_PROJECT_NAME}, projects::{Project, DEFAULT_PROJECT_NAME},
schema::{team_memberships, teams}, schema::{team_memberships, teams},
settings::Settings, settings::Settings,
@ -37,6 +37,7 @@ pub fn new_router() -> Router<AppState> {
async fn teams_page( async fn teams_page(
State(Settings { base_path, .. }): State<Settings>, State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(conn): DbConn, DbConn(conn): DbConn,
CurrentUser(current_user): CurrentUser, CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@ -48,24 +49,21 @@ async fn teams_page(
.context("failed to load team memberships") .context("failed to load team memberships")
.map(|memberships| memberships.into_iter().map(|(_, team)| team).collect())? .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)] #[derive(Template)]
#[template(path = "teams.html")] #[template(path = "teams.html")]
struct ResponseTemplate { struct ResponseTemplate {
base_path: String, base_path: String,
breadcrumbs: BreadcrumbTrail,
navbar: Navbar,
teams: Vec<Team>, teams: Vec<Team>,
nav_state: NavState,
} }
Ok(Html( Ok(Html(
ResponseTemplate { ResponseTemplate {
breadcrumbs: BreadcrumbTrail::from_base_path(&base_path)
.with_i18n_slug("en")
.push_slug("Teams", "teams"),
base_path, base_path,
nav_state, navbar: navbar_template.with_active_item(NAVBAR_ITEM_TEAMS).build(),
teams, teams,
} }
.render()?, .render()?,
@ -105,30 +103,28 @@ async fn post_new_api_key(
async fn new_team_page( async fn new_team_page(
State(Settings { base_path, .. }): State<Settings>, State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn, DbConn(db_conn): DbConn,
CurrentUser(current_user): CurrentUser, CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; 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)] #[derive(Template)]
#[template(path = "new-team.html")] #[template(path = "new-team.html")]
struct ResponseTemplate { struct ResponseTemplate {
base_path: String, base_path: String,
breadcrumbs: BreadcrumbTrail,
csrf_token: String, csrf_token: String,
nav_state: NavState, navbar: Navbar,
} }
Ok(Html( Ok(Html(
ResponseTemplate { ResponseTemplate {
breadcrumbs: BreadcrumbTrail::from_base_path(&base_path)
.with_i18n_slug("en")
.push_slug("New Team", "new-team"),
base_path, base_path,
csrf_token, csrf_token,
nav_state, navbar: navbar_template.with_active_item(NAVBAR_ITEM_TEAMS).build(),
} }
.render()?, .render()?,
)) ))

View file

@ -1,9 +1,9 @@
<nav class="container mt-4" aria-label="breadcrumb"> <nav class="container mt-4" aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
{% for breadcrumb in nav_state.breadcrumbs.iter().rev().skip(1).rev() %} {% for breadcrumb in breadcrumbs.iter().rev().skip(1).rev() %}
<li class="breadcrumb-item"><a href="{{ breadcrumb.href }}">{{ breadcrumb.label }}</a></li> <li class="breadcrumb-item"><a href="{{ breadcrumb.href }}">{{ breadcrumb.label }}</a></li>
{% endfor %} {% endfor %}
{% if let Some(breadcrumb) = nav_state.breadcrumbs.iter().last() %} {% if let Some(breadcrumb) = breadcrumbs.iter().last() %}
<li class="breadcrumb-item active" aria-current="page">{{ breadcrumb.label }}</li> <li class="breadcrumb-item active" aria-current="page">{{ breadcrumb.label }}</li>
{% endif %} {% endif %}
</ol> </ol>

View file

@ -12,7 +12,7 @@
<section class="mb-4"> <section class="mb-4">
<form <form
method="post" method="post"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-channel" action="{{ breadcrumbs.join("update-channel") }}"
> >
<div class="mb-3"> <div class="mb-3">
<label for="channel-name-input" class="form-label">Channel Name</label> <label for="channel-name-input" class="form-label">Channel Name</label>
@ -51,7 +51,7 @@
<section class="mb-4"> <section class="mb-4">
<form <form
method="post" method="post"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient" action="{{ breadcrumbs.join("update-email-recipient") }}"
> >
<div class="mb-3"> <div class="mb-3">
<label for="channel-recipient-input" class="form-label">Recipient Email</label> <label for="channel-recipient-input" class="form-label">Recipient Email</label>
@ -81,7 +81,7 @@
<section class="mb-4"> <section class="mb-4">
<form <form
method="post" method="post"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/verify-email" action="{{ breadcrumbs.join("verify-email") }}"
> >
<div class="mb-3"> <div class="mb-3">
<label for="channel-recipient-verification-code" class="form-label"> <label for="channel-recipient-verification-code" class="form-label">
@ -114,7 +114,7 @@
<form <form
id="email-verification-form" id="email-verification-form"
method="post" method="post"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient" action="{{ breadcrumbs.join("update-email-recipient") }}"
> >
<input type="hidden" name="recipient" value="{{ email_data.recipient }}"> <input type="hidden" name="recipient" value="{{ email_data.recipient }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">

View file

@ -24,7 +24,7 @@
<li> <li>
<form <form
method="post" method="post"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/new-channel" action="{{ breadcrumbs.join("../new-channel") }}"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="channel_type" value="email"> <input type="hidden" name="channel_type" value="email">
@ -41,7 +41,7 @@
Channels are places to send messages, alerts, and so on. Once created, they Channels are places to send messages, alerts, and so on. Once created, they
can be connected to specific projects at the can be connected to specific projects at the
<a <a
href="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/projects" href="{{ breadcrumbs.join("../projects") }}"
>Projects page</a>. >Projects page</a>.
</div> </div>
<section class="mb-3"> <section class="mb-3">
@ -50,7 +50,7 @@
{% for channel in channels %} {% for channel in channels %}
<tr> <tr>
<td> <td>
<a href="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}"> <a href="{{ breadcrumbs.join(channel.id.simple().to_string().as_str()) }}">
{{ channel.name }} {{ channel.name }}
</a> </a>
</td> </td>

View file

@ -14,35 +14,17 @@
</button> </button>
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"> {% for item in navbar.items %}
<a
class="nav-link{% if nav_state.navbar_active_item == "teams" %} active{% endif %}"
{% if nav_state.navbar_active_item == "teams" %}aria-current="page"{% endif %}
href="{{ base_path }}/en/teams"
>
Teams
</a>
</li>
{% if let Some(team_id) = nav_state.team_id %}
<li class="nav-item"> <li class="nav-item">
<a <a
class="nav-link{% if nav_state.navbar_active_item == "projects" %} active{% endif %}" class="nav-link{% if navbar.active_item == Some(item.id.to_owned()) %} active{% endif %}"
{% if nav_state.navbar_active_item == "projects" %}aria-current="page"{% endif %} {% if navbar.active_item == Some(item.id.to_owned()) %}aria-current="page"{% endif %}
href="{{ base_path }}/en/teams/{{ team_id.simple() }}/projects" href="{{ item.href }}"
> >
Projects {{ item.label }}
</a> </a>
</li> </li>
<li class="nav-item"> {% endfor %}
<a
class="nav-link{% if nav_state.navbar_active_item == "channels" %} active{% endif %}"
{% if nav_state.navbar_active_item == "channels" %}aria-current="page"{% endif %}
href="{{ base_path }}/en/teams/{{ team_id.simple() }}/channels"
>
Channels
</a>
</li>
{% endif %}
</ul> </ul>
<ul class="navbar-nav ms-auto mb-2 mb-lg-0 profile-menu"> <ul class="navbar-nav ms-auto mb-2 mb-lg-0 profile-menu">
<li class="nav-item dropdown"> <li class="nav-item dropdown">

View file

@ -12,7 +12,7 @@
<h2>Enabled Channels</h2> <h2>Enabled Channels</h2>
<form <form
method="post" method="post"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/projects/{{ project.id.simple() }}/update-enabled-channels" action="{{ breadcrumbs.join("update-enabled-channels") }}"
> >
<div class="mb-3"> <div class="mb-3">
<table class="table"> <table class="table">
@ -29,7 +29,7 @@
<label for="enable-channel-switch-{{ channel.id.simple() }}"> <label for="enable-channel-switch-{{ channel.id.simple() }}">
<a <a
target="_blank" target="_blank"
href="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}" href="{{ breadcrumbs.join(format!("../../channels/{}", channel.id.simple()).as_str()) }}"
> >
{{ channel.name }} {{ channel.name }}
</a> </a>

View file

@ -38,7 +38,7 @@
{% for project in projects %} {% for project in projects %}
<tr> <tr>
<td> <td>
<a href="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/projects/{{ project.id.simple() }}"> <a href="{{ breadcrumbs.join(project.id.simple().to_string().as_str()) }}">
{{ project.name }} {{ project.name }}
</a> </a>
</td> </td>
@ -53,7 +53,7 @@
<h1 class="mb-4">API Keys</h1> <h1 class="mb-4">API Keys</h1>
</section> </section>
<section class="mb-3"> <section class="mb-3">
<form method="post" action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/new-api-key"> <form method="post" action="{{ breadcrumbs.join("../new-api-key") }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Generate Key</button> <button class="btn btn-primary" type="submit">Generate Key</button>
</form> </form>