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",
"lettre",
"oauth2",
"percent-encoding",
"rand",
"regex",
"reqwest 0.12.14",

View file

@ -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"] }

View file

@ -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,

View file

@ -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<AppState> {
async fn channels_page(
State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
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<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"),
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<Settings>,
State(navbar_template): State<NavbarBuilder>,
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()?,
))

View file

@ -25,7 +25,7 @@ mod guards;
mod messages;
mod middleware;
mod migrations;
mod nav_state;
mod nav;
mod projects;
mod projects_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,
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<AppState> {
async fn projects_page(
State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
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<ApiKey>,
nav_state: NavState,
navbar: Navbar,
projects: Vec<Project>,
}
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<Settings>,
State(navbar_template): State<NavbarBuilder>,
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<Uuid>,
nav_state: NavState,
navbar: Navbar,
project: Project,
team_channels: Vec<Channel>,
}
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()?,

View file

@ -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<AppState> {
async fn teams_page(
State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(conn): DbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
@ -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<Team>,
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<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
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()?,
))

View file

@ -1,9 +1,9 @@
<nav class="container mt-4" aria-label="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>
{% 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>
{% endif %}
</ol>

View file

@ -12,7 +12,7 @@
<section class="mb-4">
<form
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">
<label for="channel-name-input" class="form-label">Channel Name</label>
@ -51,7 +51,7 @@
<section class="mb-4">
<form
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">
<label for="channel-recipient-input" class="form-label">Recipient Email</label>
@ -81,7 +81,7 @@
<section class="mb-4">
<form
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">
<label for="channel-recipient-verification-code" class="form-label">
@ -114,7 +114,7 @@
<form
id="email-verification-form"
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="csrf_token" value="{{ csrf_token }}">

View file

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

View file

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

View file

@ -12,7 +12,7 @@
<h2>Enabled Channels</h2>
<form
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">
<table class="table">
@ -29,7 +29,7 @@
<label for="enable-channel-switch-{{ channel.id.simple() }}">
<a
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 }}
</a>

View file

@ -38,7 +38,7 @@
{% for project in projects %}
<tr>
<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 }}
</a>
</td>
@ -53,7 +53,7 @@
<h1 class="mb-4">API Keys</h1>
</section>
<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 }}">
<button class="btn btn-primary" type="submit">Generate Key</button>
</form>