diff --git a/.sqlx/query-6f540be5517aaffe1774bebe9a2c0eba835e11cd8e1b07ea44046ae795008704.json b/.sqlx/query-6f540be5517aaffe1774bebe9a2c0eba835e11cd8e1b07ea44046ae795008704.json new file mode 100644 index 0000000..6a0f189 --- /dev/null +++ b/.sqlx/query-6f540be5517aaffe1774bebe9a2c0eba835e11cd8e1b07ea44046ae795008704.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM users WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "first_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + false + ] + }, + "hash": "6f540be5517aaffe1774bebe9a2c0eba835e11cd8e1b07ea44046ae795008704" +} diff --git a/.sqlx/query-73ffdf5be39aa5c4c160c2f77d6634a6970eeb4e1d3395f045ded747f0ce9d2a.json b/.sqlx/query-73ffdf5be39aa5c4c160c2f77d6634a6970eeb4e1d3395f045ded747f0ce9d2a.json new file mode 100644 index 0000000..3427c65 --- /dev/null +++ b/.sqlx/query-73ffdf5be39aa5c4c160c2f77d6634a6970eeb4e1d3395f045ded747f0ce9d2a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM users WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "73ffdf5be39aa5c4c160c2f77d6634a6970eeb4e1d3395f045ded747f0ce9d2a" +} diff --git a/.sqlx/query-d23b45d169c59a7dd2c4756b49e6322b98a16fa81e362e138d7ce99c4a020b4f.json b/.sqlx/query-d23b45d169c59a7dd2c4756b49e6322b98a16fa81e362e138d7ce99c4a020b4f.json new file mode 100644 index 0000000..e8bbded --- /dev/null +++ b/.sqlx/query-d23b45d169c59a7dd2c4756b49e6322b98a16fa81e362e138d7ce99c4a020b4f.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT u.*\n FROM users u\n LIMIT ?\n OFFSET ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "first_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + true, + true, + false, + false + ] + }, + "hash": "d23b45d169c59a7dd2c4756b49e6322b98a16fa81e362e138d7ce99c4a020b4f" +} diff --git a/.sqlx/query-e3f19de7c663b21e992056bdce331c5aac9eded9d0381e42b39d06e79547c5a6.json b/.sqlx/query-e3f19de7c663b21e992056bdce331c5aac9eded9d0381e42b39d06e79547c5a6.json new file mode 100644 index 0000000..c0cf8d1 --- /dev/null +++ b/.sqlx/query-e3f19de7c663b21e992056bdce331c5aac9eded9d0381e42b39d06e79547c5a6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM user_roles WHERE user_id=? AND role=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "e3f19de7c663b21e992056bdce331c5aac9eded9d0381e42b39d06e79547c5a6" +} diff --git a/config/seed_data.toml b/config/seed_data.toml index 066ff87..1a6c998 100644 --- a/config/seed_data.toml +++ b/config/seed_data.toml @@ -1,7 +1,140 @@ [[users]] -email = "test@test.com" -first_name = "Test" +email = "test1@test.com" +first_name = "Test1" last_name = "Testington" +[[users]] +email = "test2@test.com" +first_name = "Test2" +last_name = "Testington" +[[users]] +email = "test3@test.com" +first_name = "Test3" +last_name = "Testington" +[[users]] +email = "test4@test.com" +first_name = "Test4" +last_name = "Testington" +[[users]] +email = "test5@test.com" +first_name = "Test5" +last_name = "Testington" +[[users]] +email = "test6@test.com" +first_name = "Test6" +last_name = "Testington" +[[users]] +email = "test7@test.com" +first_name = "Test7" +last_name = "Testington" +[[users]] +email = "test8@test.com" +first_name = "Test8" +last_name = "Testington" +[[users]] +email = "test9@test.com" +first_name = "Test9" +last_name = "Testington" +[[users]] +email = "test10@test.com" +first_name = "Test10" +last_name = "Testington" +[[users]] +email = "test11@test.com" +first_name = "Test11" +last_name = "Testington" +[[users]] +email = "test12@test.com" +first_name = "Test12" +last_name = "Testington" +[[users]] +email = "test13@test.com" +first_name = "Test13" +last_name = "Testington" +[[users]] +email = "test14@test.com" +first_name = "Test14" +last_name = "Testington" +[[users]] +email = "test15@test.com" +first_name = "Test15" +last_name = "Testington" +[[users]] +email = "test16@test.com" +first_name = "Test16" +last_name = "Testington" +[[users]] +email = "test17@test.com" +first_name = "Test17" +last_name = "Testington" +[[users]] +email = "test18@test.com" +first_name = "Test18" +last_name = "Testington" +[[users]] +email = "test19@test.com" +first_name = "Test19" +last_name = "Testington" +[[users]] +email = "test20@test.com" +first_name = "Test20" +last_name = "Testington" +[[users]] +email = "test21@test.com" +first_name = "Test21" +last_name = "Testington" +[[users]] +email = "test22@test.com" +first_name = "Test22" +last_name = "Testington" +[[users]] +email = "test23@test.com" +first_name = "Test23" +last_name = "Testington" +[[users]] +email = "test24@test.com" +first_name = "Test24" +last_name = "Testington" +[[users]] +email = "test25@test.com" +first_name = "Test25" +last_name = "Testington" +[[users]] +email = "test26@test.com" +first_name = "Test26" +last_name = "Testington" +[[users]] +email = "test27@test.com" +first_name = "Test27" +last_name = "Testington" +[[users]] +email = "test28@test.com" +first_name = "Test28" +last_name = "Testington" +[[users]] +email = "test29@test.com" +first_name = "Test29" +last_name = "Testington" +[[users]] +email = "test30@test.com" +first_name = "Test30" +last_name = "Testington" +[[users]] +email = "test31@test.com" +first_name = "Test31" +last_name = "Testington" +[[users]] +email = "test32@test.com" +first_name = "Test32" +last_name = "Testington" +[[users]] +email = "test33@test.com" +first_name = "Test33" +last_name = "Testington" +[[users]] +email = "test34@test.com" +first_name = "Test34" +last_name = "Testington" + [[user_roles]] user_id = 1 diff --git a/frontend/styles/admin/dashboard.css b/frontend/styles/admin/dashboard.css new file mode 100644 index 0000000..4dbe570 --- /dev/null +++ b/frontend/styles/admin/dashboard.css @@ -0,0 +1,47 @@ +#admin\/ { + @apply flex h-[calc(100vh-59px)] max-h-[calc(100vh-59px)] min-h-[calc(100vh-59px)] flex-row; +} +#sidebar { + @apply relative z-30 flex min-w-64 flex-col border-r-1 border-neutral-800 p-2; + > a { + @apply m-1 flex cursor-pointer items-center rounded-sm border border-transparent p-2 text-sm text-neutral-600 no-underline hover:bg-neutral-800 hover:text-white; + &.active { + @apply border border-neutral-700 bg-neutral-900 text-white hover:bg-neutral-800; + svg { + @apply text-white; + } + } + span { + @apply ms-2; + } + svg { + @apply fill-current text-neutral-600; + } + &:hover { + svg { + @apply text-white; + } + } + } +} +#admin\/dashboard { + @apply flex flex-1 flex-col overflow-auto p-3; +} +.widget-container { + @apply rounded-sm border border-neutral-800 bg-neutral-950 p-4; + a > .widget-nav { + @apply ml-auto h-7 w-7 cursor-pointer rounded-sm p-1 hover:bg-neutral-800; + } + .widget-content-stat { + @apply flex flex-col items-center; + h1 { + @apply text-5xl; + } + h2 { + @apply text-xl text-neutral-400; + } + } +} + +@import "./dashboard/overview.css"; +@import "./dashboard/users.css"; diff --git a/frontend/styles/admin/dashboard/overview.css b/frontend/styles/admin/dashboard/overview.css new file mode 100644 index 0000000..bcc8757 --- /dev/null +++ b/frontend/styles/admin/dashboard/overview.css @@ -0,0 +1,45 @@ +#admin\/dashboard\/overview { + @apply grid h-full w-full grid-cols-3 gap-2; + grid-template-rows: 0.25fr minmax(0, 1fr); +} + +#widget-attendees { + @apply col-start-1 col-end-4 row-start-2 row-end-3 flex h-full w-full flex-col; + .widget-content { + @apply flex-1 overflow-auto; + #chart1 { + .ct-series-a .ct-line { + @apply stroke-green-600 stroke-2; + } + .ct-series-a .ct-point { + @apply stroke-green-600; + } + .ct-series-a .ct-area { + @apply fill-green-600; + } + .ct-label { + @apply fill-white text-white; + } + + .ct-horizontal:first-child { + @apply stroke-neutral-500; + } + + .ct-vertical:nth-child(6) { + @apply stroke-neutral-500; + } + + .ct-axis-title, + .ct-axis-title text { + @apply fill-white; + } + + .chartist-tooltip { + @apply absolute z-100 hidden min-w-5 cursor-none rounded-sm border-1 border-neutral-500 bg-neutral-800 px-2.5 py-2 text-center transition-opacity delay-200 ease-linear; + } + .chartist-tooltip.show { + @apply block; + } + } + } +} diff --git a/frontend/styles/admin/dashboard/users.css b/frontend/styles/admin/dashboard/users.css new file mode 100644 index 0000000..f39cf2b --- /dev/null +++ b/frontend/styles/admin/dashboard/users.css @@ -0,0 +1,38 @@ +#admin\/dashboard\/users { + @apply grid h-full w-full grid-cols-3 gap-2; + grid-template-rows: 0.1fr minmax(0, 1fr); +} + +#widget-table { + @apply col-start-1 col-end-4 row-start-2 row-end-3 flex w-full flex-col; + .widget-item-table { + @apply h-full w-full flex-1 overflow-auto text-left; + } + table { + @apply w-full border-separate border-spacing-0 text-left; + thead { + th { + @apply sticky top-0 border-b border-neutral-700; + } + } + td, + th { + @apply py-1.5 text-sm overflow-ellipsis; + } + th { + @apply sticky bg-black; + } + td { + @apply border-b border-neutral-800; + } + input { + @apply h-3.5 w-3.5 rounded-sm border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600; + } + } + .table-footer { + @apply py-1.5; + a { + @apply inline-flex w-12 justify-center rounded-sm border-1 border-neutral-700 py-1.5 first-of-type:mr-3; + } + } +} diff --git a/frontend/styles/extentions/form.css b/frontend/styles/extentions/form.css index 6198f9e..59266b7 100644 --- a/frontend/styles/extentions/form.css +++ b/frontend/styles/extentions/form.css @@ -54,6 +54,9 @@ &.\:red { @apply border-lsd-red/30 bg-lsd-red/20 hover:bg-lsd-red/30; } + &.\:disabled { + @apply border-lsd-white/10 bg-lsd-white/5 hover:bg-lsd-white/5 cursor-default; + } } .ext\/button:disabled { diff --git a/frontend/styles/main.css b/frontend/styles/main.css index 602e982..5915c25 100644 --- a/frontend/styles/main.css +++ b/frontend/styles/main.css @@ -69,3 +69,6 @@ body > header { @import "./posts/list.css"; @import "./posts/send.css"; @import "./posts/view.css"; + +/* Admin */ +@import "./admin/dashboard.css"; diff --git a/frontend/templates/admin/dashboard.html b/frontend/templates/admin/dashboard.html new file mode 100644 index 0000000..86db7c0 --- /dev/null +++ b/frontend/templates/admin/dashboard.html @@ -0,0 +1,138 @@ +{% extends "layout.html" %} +{% block title %}light and sound - admin{% endblock title %} +{% block styles %} + {% call super() %} +{% endblock styles %} +{% block content %} +
+ +
+ {% block panel %} + {% endblock panel %} +
+
+ + +{% endblock content %} diff --git a/frontend/templates/admin/dashboard/events.html b/frontend/templates/admin/dashboard/events.html new file mode 100644 index 0000000..3897be2 --- /dev/null +++ b/frontend/templates/admin/dashboard/events.html @@ -0,0 +1,4 @@ +{% extends "admin/dashboard.html" %} +{% block title %}{% call super() %} overview{% endblock title %} +{% block panel %} +{% endblock panel %} diff --git a/frontend/templates/admin/dashboard/newsletter.html b/frontend/templates/admin/dashboard/newsletter.html new file mode 100644 index 0000000..3897be2 --- /dev/null +++ b/frontend/templates/admin/dashboard/newsletter.html @@ -0,0 +1,4 @@ +{% extends "admin/dashboard.html" %} +{% block title %}{% call super() %} overview{% endblock title %} +{% block panel %} +{% endblock panel %} diff --git a/frontend/templates/admin/dashboard/overview.html b/frontend/templates/admin/dashboard/overview.html new file mode 100644 index 0000000..ae41bb2 --- /dev/null +++ b/frontend/templates/admin/dashboard/overview.html @@ -0,0 +1,292 @@ +{% extends "admin/dashboard.html" %} +{% block title %}{% call super() %} overview{% endblock title %} +{% block panel %} +
+
+ + + +
+

+ {{ users_count }} +

+

New Users

+
+
+
+ + + +
+

+ 7000 +

+

Newsletters Opened

+
+
+
+ + + +
+

+ 15 +

+

Events

+
+
+
+ + + +
+
+
+
+
+ + + +{% endblock panel %} +{% block scripts %} +{% endblock scripts %} diff --git a/frontend/templates/admin/dashboard/users.html b/frontend/templates/admin/dashboard/users.html new file mode 100644 index 0000000..26a5fbc --- /dev/null +++ b/frontend/templates/admin/dashboard/users.html @@ -0,0 +1,160 @@ +{% extends "admin/dashboard.html" %} +{% block title %}{% call super() %} overview{% endblock title %} +{% block panel %} +
+
+
+

+ 200 +

+

New Users

+
+
+
+
+

+ 240 +

+

New Users

+
+
+
+
+

+ 100 +

+

New Users

+
+
+
+
+ + + + + + + + + + + {% for user in users %} + + + + + + + + + {% endfor %} + +
First NameLast NameEmailRole: WriterRole: AdminRemove user
{{ user.first_name }}{{ user.last_name }}{{ user.email }} + + + + + +
+
+ +
+
+ +{% endblock panel %} diff --git a/src/app/admin.rs b/src/app/admin.rs new file mode 100644 index 0000000..e5284cb --- /dev/null +++ b/src/app/admin.rs @@ -0,0 +1,101 @@ +use crate::db::user::{ListUserQuery, User, UserView}; +use crate::prelude::*; + +/// Add all `admins` routes to the router. +pub fn add_routes(router: AppRouter) -> AppRouter { + router.restricted_routes(User::ADMIN, |r| { + r.route("/admin/dashboard/overview", get(view::overview)) + .route("/admin/dashboard/users", get(view::users)) + .route("/admin/action/users/{url}/changeRole", post(action::change_user_role)) + .route("/admin/action/users/{url}/remove", delete(action::remove_user)) + }) +} + +mod action { + + use super::*; + + // Change user role + #[derive(serde::Deserialize)] + pub struct ActionQuery { + action: String, + role: String, + } + + // Change user's add/remove user role + pub async fn change_user_role( + State(state): State, + Path(url): Path, + Query(query): Query, + user: User, + ) -> AppResult { + if user.has_role(&state.db, "admin").await? { + let Some(_) = User::lookup_by_id(&state.db, url).await? else { + return Err(AppError::NotFound); + }; + + match query.action.as_str() { + "remove" => { + User::remove_role(&state.db, url, &query.role).await?; + Ok(()) + } + "add" => { + User::add_role(&state.db, url, &query.role).await?; + Ok(()) + } + _ => Err(AppError::NotAuthorized), + } + } else { + Err(AppError::NotAuthorized) + } + } + + pub async fn remove_user( + State(state): State, + Path(url): Path, + user: User, + ) -> AppResult { + if user.has_role(&state.db, "admin").await? { + let Some(_) = User::lookup_by_id(&state.db, url).await? else { + return Err(AppError::NotFound); + }; + User::remove(&state.db, url).await?; + Ok(()) + } else { + Err(AppError::NotAuthorized) + } + } +} + +mod view { + use super::*; + /// Display admin overview dashboard + pub async fn overview(State(state): State) -> AppResult { + let q = ListUserQuery { page: 0, page_size: 25 }; + + let users_count = User::list(&state.db, &q).await?.users.len(); + + #[derive(Template, WebTemplate)] + #[template(path = "admin/dashboard/overview.html")] + pub struct Html { + pub users_count: usize, + } + Ok(Html { users_count }) + } + + // Display table of users dashboard + pub async fn users( + State(state): State, + Query(query): Query, + ) -> AppResult { + let query_result = User::list(&state.db, &query).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "admin/dashboard/users.html")] + pub struct Html { + pub users: Vec, + pub has_next_page: bool, + } + Ok(Html { users: query_result.users, has_next_page: query_result.has_next_page }) + } +} diff --git a/src/app/auth.rs b/src/app/auth.rs index 514c500..4ead71c 100644 --- a/src/app/auth.rs +++ b/src/app/auth.rs @@ -59,6 +59,22 @@ pub async fn auth_middleware( Ok((cookies, response)) } +// // Middleware to be used to require admin role for certain routes (ie Admin dashboard) +// pub async fn require_admin( +// State(state): State, +// request: Request, +// next: Next, +// ) -> AppResult { +// let user = request.extensions().get::().ok_or(AppError::NotAuthorized)?; + +// // Check if user has admin role +// if !user.has_role(&state.db, "admin").await? { +// return Err(AppError::NotAuthorized); +// } + +// Ok(next.run(request).await) +// } + /// Process a login form and send either a login or registration link via email. async fn login_form( State(state): State, diff --git a/src/app/mod.rs b/src/app/mod.rs index 201e657..6d89b6b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,7 @@ use crate::prelude::*; use crate::utils::emailer::Emailer; +mod admin; mod auth; mod emails; mod events; @@ -31,6 +32,7 @@ pub async fn build(config: Config) -> Result> { let r = events::add_routes(r); let r = lists::add_routes(r); let r = emails::add_routes(r); + let r = admin::add_routes(r); let (r, state) = r.finish(); // Register app-wide routes diff --git a/src/db/user.rs b/src/db/user.rs index d2ba474..9a34920 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -27,6 +27,38 @@ pub struct UpdateUser { pub email: String, } +#[derive(Debug, serde::Deserialize)] +pub struct UserView { + pub id: i64, + pub first_name: String, + pub last_name: String, + pub email: String, + pub is_admin: bool, + pub is_writer: bool, +} +impl From for UserView { + fn from(user: User) -> Self { + Self { + id: user.id, + first_name: user.first_name.unwrap_or_default(), + last_name: user.last_name.unwrap_or_default(), + email: user.email, + is_writer: false, + is_admin: false, + } + } +} +pub struct UserViewList { + pub users: Vec, + pub has_next_page: bool, +} + +#[derive(Debug, serde::Deserialize)] +pub struct ListUserQuery { + pub page: i64, + pub page_size: i64, +} + impl User { /// Full access to everything. pub const ADMIN: &'static str = "admin"; @@ -48,6 +80,13 @@ impl User { Ok(row.last_insert_rowid()) } + /// Delete a user + pub async fn remove(db: &Db, user_id: i64) -> AppResult<()> { + sqlx::query!(r#"DELETE FROM users WHERE id = ?"#, user_id).execute(db).await?; + Ok(()) + } + + /// Add role to a user pub async fn add_role(db: &Db, user_id: i64, role: &str) -> AppResult<()> { sqlx::query!(r#"INSERT INTO user_roles (user_id, role) VALUES (?, ?)"#, user_id, role) .execute(db) @@ -55,6 +94,22 @@ impl User { Ok(()) } + /// Remove role to a user + pub async fn remove_role(db: &Db, user_id: i64, role: &str) -> AppResult<()> { + sqlx::query!(r#"DELETE FROM user_roles WHERE user_id=? AND role=?"#, user_id, role) + .execute(db) + .await?; + Ok(()) + } + + /// Look up user by user_id, if one exists. + pub async fn lookup_by_id(db: &Db, user_id: i64) -> AppResult> { + let row = sqlx::query_as!(Self, "SELECT * FROM users WHERE id = ?", user_id) + .fetch_optional(db) + .await?; + Ok(row) + } + /// Lookup a user by email address, if one exists. pub async fn lookup_by_email(db: &Db, email: &str) -> AppResult> { let row = sqlx::query_as!(Self, "SELECT * FROM users WHERE email = ?", email) @@ -106,4 +161,60 @@ impl User { .await?; Ok(row.is_some()) } + + //Query users based on params + pub async fn list(db: &Db, query: &ListUserQuery) -> AppResult { + let current_page = (query.page - 1) * query.page_size; + let next_page_check = query.page_size + 1; + let mut users = sqlx::query_as!( + User, + r#"SELECT u.* + FROM users u + LIMIT ? + OFFSET ?"#, + next_page_check, + current_page, + ) + .fetch_all(db) + .await?; + + let has_next_page = users.len() > query.page_size as usize; + users = if has_next_page { users[0..query.page_size as usize].to_vec() } else { users }; + + let user_ids: Vec = users.iter().map(|u| u.id).collect(); + + let user_roles: Vec<(i64, String)> = if !user_ids.is_empty() { + let placeholders = user_ids.iter().map(|_| "?").collect::>().join(","); + let query_str = + format!("SELECT user_id, role FROM user_roles WHERE user_id IN ({})", placeholders); + let mut query = sqlx::query_as(&query_str); + for user_id in &user_ids { + query = query.bind(user_id); + } + query.fetch_all(db).await? + } else { + Vec::new() + }; + + let user_view_list: Vec = users + .into_iter() + .map(|user| { + let user_roles_for_this_user: Vec = user_roles + .iter() + .filter(|(user_id, _)| *user_id == user.id) + .map(|(_, role)| role.clone()) + .collect(); + UserView { + id: user.id, + first_name: user.first_name.unwrap_or_default(), + last_name: user.last_name.unwrap_or_default(), + email: user.email, + is_writer: user_roles_for_this_user.contains(&"writer".to_string()), + is_admin: user_roles_for_this_user.contains(&"admin".to_string()), + } + }) + .collect(); + + Ok(UserViewList { users: user_view_list, has_next_page }) + } } diff --git a/src/views/admin.rs b/src/views/admin.rs new file mode 100644 index 0000000..4eb70fd --- /dev/null +++ b/src/views/admin.rs @@ -0,0 +1,15 @@ +use crate::db::user::UserView; +use askama::Template; +use askama_web::WebTemplate; + +#[derive(Template, WebTemplate)] +#[template(path = "admin/dashboard/overview.html")] +pub struct AdminDashboardOverview { + pub users_count: usize, +} + +#[derive(Template, WebTemplate)] +#[template(path = "admin/dashboard/users.html")] +pub struct AdminDashboardUsersView { + pub users: Vec, +}