forked from 2sys/shoutdotdev
refactor nav state data structures
This commit is contained in:
parent
29960e0b4e
commit
bef9cc4cca
15 changed files with 323 additions and 189 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -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",
|
||||||
|
|
|
@ -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"] }
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()?,
|
||||||
))
|
))
|
||||||
|
|
|
@ -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
230
src/nav.rs
Normal 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,
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()?,
|
||||||
|
|
|
@ -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()?,
|
||||||
))
|
))
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 }}">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Reference in a new issue