diff --git a/app/public/style.css b/app/public/style.css index 2c6aa73..07d3d5f 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -17,6 +17,10 @@ --color-green-lowered: hsl(100, 84%, 15%); --color-red-lowered: hsl(0, 84%, 35%); + --color-primary: hsl(25, 95%, 53%); + --color-primary-lowered: hsl(25, 95%, 49%); + --color-text-primary: hsl(0, 0%, 5%); + --shadow-x-offset: 0; --shadow-y-offset: 0.125rem; --shadow-size: var(--pad-1); @@ -51,6 +55,10 @@ --color-green: hsl(100, 94%, 82%); --color-yellow: oklch(90.1% 0.076 70.697); --color-purple: hsl(284, 94%, 82%); + + --color-primary: hsl(25, 95%, 63%); + --color-primary-lowered: hsl(25, 95%, 59%); + --color-text-primary: hsl(0, 0%, 5%); } html, @@ -94,7 +102,7 @@ article { overflow: auto; } -.tabs .tab { +.tabs:not(.short) .tab { height: 100%; } @@ -195,6 +203,10 @@ video { padding: var(--pad-2) var(--pad-4); } +.card.surface { + background: var(--color-surface); +} + /* button */ .button { --h: 36px; @@ -215,6 +227,7 @@ video { text-decoration: none !important; user-select: none; appearance: none; + overflow: hidden; } .button:disabled { @@ -276,6 +289,15 @@ video { } } +.button.primary { + color: var(--color-text-primary) !important; + background: var(--color-primary); +} + +.button.primary:hover { + background: var(--color-primary-lowered) !important; +} + /* dropdown */ .dropdown { position: relative; @@ -468,6 +490,12 @@ svg.icon { fill: currentColor; } +.big_icon svg.icon { + width: 18px; + height: 18px; + position: absolute; +} + button svg { pointer-events: none; } @@ -621,6 +649,10 @@ span { align-items: flex-end; } +.gap_ch { + gap: 1ch; +} + /* table */ table { width: 100%; @@ -675,7 +707,7 @@ dialog { border: 0; } -dialog.inner { +dialog .inner { display: flex; flex-direction: column; gap: var(--pad-2); diff --git a/app/templates_src/chat.lisp b/app/templates_src/chat.lisp new file mode 100644 index 0000000..7b950d8 --- /dev/null +++ b/app/templates_src/chat.lisp @@ -0,0 +1,26 @@ +(text "{% extends \"root.lisp\" %} {% block head %}") +(title + (text "{{ components::chat_name(chat=chat, members=members) }} - {{ name }}")) +(text "{% endblock %} {% block body %}") +(div + ("class" "flex w_full gap_2 justify_between items_center") + (div + ("class" "tabs short bar flex") + (a + ("class" "button tab camo") + ("href" "/chats") + (text "chats")) + (a + ("class" "button tab") + ("href" "/chats/{{ chat.id }}") + (text "{{ components::chat_name(chat=chat, members=members, advanced=true, avatar_size=\"18px\") }}")))) +(div + ("class" "card flex flex_col gap_2") + ("style" "flex: 1 0 auto") + (text "{% if messages|length == 0 -%}") + (i ("class" "flex gap_ch items_center fade") (text "{{ icon \"star\" }} This is the start of the chat!")) + (text "{%- endif %}")) + +(script + (text "")) +(text "{% endblock %}") diff --git a/app/templates_src/chats.lisp b/app/templates_src/chats.lisp new file mode 100644 index 0000000..41b9bda --- /dev/null +++ b/app/templates_src/chats.lisp @@ -0,0 +1,217 @@ +(text "{% extends \"root.lisp\" %} {% block head %}") +(title + (text "My chats - {{ name }}")) +(text "{% endblock %} {% block body %}") +(div + ("class" "flex w_full gap_2 justify_between items_center") + (div + ("class" "tabs short bar") + (a + ("class" "button tab") + ("href" "/chats") + (text "chats"))) + (button + ("class" "button") + ("title" "Create chat") + ("onclick" "document.getElementById('create_dialog').showModal()") + (text "{{ icon \"plus\" }}"))) +(div + ("class" "card flex flex_col gap_2") + (text "{% for chat in chats -%}") + (div + ("class" "card surface w_full flex justify_between items_center gap_2") + (a + ("class" "flex gap_ch items_center") + ("href" "/chats/{{ chat[0].id }}") + (text "{{ components::chat_name(chat=chat[0], members=chat[1], advanced=true) }}")) + (div + ("class" "dropdown") + (button + ("onclick" "open_dropdown(event)") + ("exclude" "dropdown") + ("class" "button") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (a + ("class" "button") + ("href" "/chats/{{ chat[0].id }}") + ("target" "_blank") + (text "pop open")) + + (text "{% if chat[0].style != \"Direct\" -%}") + ; group chat only + (button + ("class" "button") + ("onclick" "rename_gc('{{ chat[0].id }}')") + (text "rename")) + (text "{%- endif %}") + + (button + ("class" "button red") + ("onclick" "leave_chat('{{ chat[0].id }}')") + (text "leave"))))) + (text "{%- endfor %}") + + (text "{% if chats|length == 0 -%}") + (i ("class" "flex gap_ch items_center fade") (text "{{ icon \"smile\" }} No results, yet!")) + (text "{%- endif %}") + + ; pagination + (div + ("class" "flex w_full items_center gap_2 justify_between") + (text "{% if page > 0 -%}") + (a + ("href" "?page={{ page - 1 }}") + ("class" "button surface") + (text "{{ icon \"arrow-left\" }} Back")) + (text "{%- else -%}") + (div null?) + (text "{%- endif %}") + + (text "{% if chats|length > 0 -%}") + (a + ("href" "?page={{ page + 1 }}") + ("class" "button surface") + (text "Next {{ icon \"arrow-right\" }}")) + (text "{%- endif %}"))) + +(dialog + ("id" "create_dialog") + (div + ("class" "inner") + (h2 + ("class" "text_center w_full") + (text "Create chat")) + + (ul ("id" "members_list")) + (form + ("class" "flex flex_row gap_2 w_full") + ("onsubmit" "add_member(event)") + (input + ("type" "text") + ("list" "users_search") + ("name" "username") + ("id" "username") + ("placeholder" "username") + ("oninput" "search_users(event)")) + (button + ("class" "button") + (text "Add"))) + + (hr ("class" "margin")) + + (div + ("class" "flex gap_2 justify_between") + (button + ("onclick" "document.getElementById('create_dialog').close()") + ("class" "button red") + (text "Cancel")) + + (button + ("class" "button green") + ("onclick" "create_chat_from_form()") + (text "Create"))))) + +(datalist ("id" "users_search")) + +(script + (text "globalThis.CHAT_MEMBERS = []; + + function render_members() { + document.getElementById('members_list').innerHTML = \"\"; + for (const member of CHAT_MEMBERS) { + document.getElementById('members_list').innerHTML += `
  • ${member} (remove)
  • `; + } + } + + function add_member(e) { + e.preventDefault(); + const member = e.target.username.value; + + if (CHAT_MEMBERS.includes(member)) { + return; + } + + CHAT_MEMBERS.push(member); + render_members(); + e.target.reset(); + } + + function remove_member(member) { + CHAT_MEMBERS.splice(CHAT_MEMBERS.indexOf(member), 1); + render_members(); + } + + function create_chat_from_form(e) { + document.getElementById('create_dialog').close(); + + fetch(\"/api/v1/chats\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + style: CHAT_MEMBERS.length > 1 ? { Group: { name: \"Untitled\" } } : \"Direct\", + members: CHAT_MEMBERS, + }), + }) + .then((res) => res.json()) + .then((res) => { + show_message(res.message, res.ok); + + if (res.ok) { + window.location.href = `/chats/${res.payload}`; + e.target.reset(); + } + }); + } + + let search_users_timeout; + function search_users(e) { + if (search_users_timeout) { + clearTimeout(search_users_timeout); + } + + if (e.target.value.trim().length === 0) { + return; + } + + search_users_timeout = setTimeout(() => { + fetch(\"/api/v1/auth/users/search\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + prefix: e.target.value.trim(), + }), + }) + .then((res) => res.json()) + .then((res) => { + if (res.ok) { + document.getElementById(\"users_search\").innerHTML = \"\"; + for (const username of res.payload) { + document.getElementById(\"users_search\").innerHTML += ``; + } + } else { + show_message(res.message, res.ok); + } + }); + }, 1000); + } + + function leave_chat(id) { + if (!confirm(\"Are you sure you would like to do this?\")) { + return; + } + + fetch(`/api/v1/chats/${id}/leave`, { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + show_message(res.message, res.ok); + }); + }")) +(text "{% endblock %}") diff --git a/app/templates_src/components.lisp b/app/templates_src/components.lisp index d8a0f1d..3fa1ee1 100644 --- a/app/templates_src/components.lisp +++ b/app/templates_src/components.lisp @@ -7,3 +7,36 @@ ("loading" "lazy") ("style" "--size: {{ size }}")) (text "{%- endmacro %}") + +(text "{% macro username(user) -%}") +(b + (text "{% if user.settings.display_name|length > 0 -%}") + (text "{{ user.settings.display_name }}") + (text "{%- else -%}") + (text "{{ user.username }}") + (text "{%- endif %}")) +(text "{%- endmacro %}") + +(text "{% macro chat_name(chat, members, advanced=false, avatar_size=\"24px\") -%}") +(text "{% if advanced -%}") +; advanced +(text "{% if chat.style == \"Direct\" -%} {% for member in members -%} {% if member.id != user.id -%}") +; direct message; user that ISN'T the current user +(text "{{ components::avatar(id=member.id, size=avatar_size) }}") +(text "{{ components::username(user=member) }}") +(text "{%- endif %} {%- endfor %} {%- else -%}") +; group chat +(text "{% for member in members -%} {{ components::avatar(id=member.id, size=avatar_size) }} {%- endfor %}") +(b (text "{{ chat.style.Group.name }}")) +(text "{%- endif %}") +(text "{%- else -%}") +; NOT advanced +(text "{% if chat.style == \"Direct\" -%} {% for member in members -%} {% if member.id != user.id -%}") +; direct message; user that ISN'T the current user +(text "{{ user.username }}") +(text "{%- endif %} {%- endfor %} {%- else -%}") +; group chat +(text "{{ chat.style.Group.name }}") +(text "{%- endif %}") +(text "{%- endif %}") +(text "{%- endmacro %}") diff --git a/app/templates_src/root.lisp b/app/templates_src/root.lisp index 87e742b..cb97232 100644 --- a/app/templates_src/root.lisp +++ b/app/templates_src/root.lisp @@ -10,8 +10,6 @@ (link ("rel" "stylesheet") ("href" "https://repodelivery.tetratto.com/tetratto/crates/app/src/public/css/utility.css")) (link ("rel" "stylesheet") ("href" "/public/style.css?v={{ build_code }}")) - (style (text ":root { --color-primary: {{ theme_color }}; }")) - (meta ("name" "theme-color") ("content" "{{ theme_color }}")) (meta ("property" "og:type") ("content" "website")) (meta ("property" "og:site_name") ("content" "{{ name }}")) @@ -28,7 +26,7 @@ (body ; nav (nav - ("class" "flex w_full justify_between gap_2 sticky") + ("class" "flex w_full justify_between gap_2") (div ("class" "flex side") (div @@ -76,8 +74,7 @@ ("onclick" "user_logout()") (text "logout")) (text "{%- endif %}") - (text "{% block dropdown %}{% endblock %}"))) - (a ("class" "button camo") ("href" "/") (b (text "{{ name }}")))) + (text "{% block dropdown %}{% endblock %}")))) (div ("class" "side flex") diff --git a/src/database/chats.rs b/src/database/chats.rs index 920089d..b104c6d 100644 --- a/src/database/chats.rs +++ b/src/database/chats.rs @@ -1,9 +1,9 @@ use super::DataManager; use crate::model::{Chat, ChatStyle}; -use oiseau::{PostgresRow, cache::Cache, execute, get, params}; +use oiseau::{PostgresRow, cache::Cache, execute, get, params, query_rows}; use tetratto_core::{ auto_method, - model::{Error, Result}, + model::{Error, Result, auth::User}, }; impl DataManager { @@ -21,6 +21,59 @@ impl DataManager { auto_method!(get_chat_by_id(usize as i64)@get_chat_from_row -> "SELECT * FROM t_chats WHERE id = $1" --name="chat" --returns=Chat --cache-key-tmpl="twny.chat:{}"); + pub async fn fill_chat(&self, chat: Chat) -> (Chat, Vec) { + let mut members = Vec::new(); + + for x in &chat.members { + members.push(match self.2.get_user_by_id(*x).await { + Ok(x) => x, + Err(_) => User::deleted(), + }); + } + + (chat, members) + } + + pub async fn fill_chats(&self, chats: Vec) -> Vec<(Chat, Vec)> { + let mut out = Vec::new(); + + for x in chats { + out.push(self.fill_chat(x).await); + } + + out + } + + /// Get all chats the given member is participating in. + pub async fn get_chats_by_member( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM t_chats WHERE members LIKE $1 ORDER BY last_message_created LIMIT $2 OFFSET $3", + params![ + &format!("%{id}%"), + &(batch as i64), + &((page * batch) as i64) + ], + |x| { Self::get_chat_from_row(x) } + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(res.unwrap()) + } + /// Create a new chat in the database. /// /// # Arguments diff --git a/src/database/messages.rs b/src/database/messages.rs index d49e145..6848a1f 100644 --- a/src/database/messages.rs +++ b/src/database/messages.rs @@ -3,7 +3,7 @@ use crate::model::{Message, SocketMessage, SocketMethod}; use oiseau::{ PostgresRow, cache::{Cache, redis::Commands}, - execute, get, params, + execute, get, params, query_rows, }; use tetratto_core::{ auto_method, @@ -27,6 +27,32 @@ impl DataManager { auto_method!(get_message_by_id(usize as i64)@get_message_from_row -> "SELECT * FROM t_messages WHERE id = $1" --name="message" --returns=Message --cache-key-tmpl="twny.message:{}"); + /// Get messages by their chat ID. + pub async fn get_messages_by_chat( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM t_messages WHERE chat = $1 LIMIT $2 OFFSET $3", + params![&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_message_from_row(x) } + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(res.unwrap()) + } + /// Create a new message in the database. /// /// # Arguments diff --git a/src/database/mod.rs b/src/database/mod.rs index 5f1667a..a7e6bd4 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,6 +1,7 @@ mod chats; mod messages; mod sql; +mod users; use crate::config::Config; use buckets_core::{Config as BucketsConfig, DataManager as BucketsManager}; diff --git a/src/database/sql/create_messages.sql b/src/database/sql/create_messages.sql index 32aaa5e..24e1dd9 100644 --- a/src/database/sql/create_messages.sql +++ b/src/database/sql/create_messages.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS t_messages ( created BIGINT NOT NULL, edited BIGINT NOT NULL, owner BIGINT NOT NULL, - chats BIGINT NOT NULL, + chat BIGINT NOT NULL, content TEXT NOT NULL, uploads TEXT NOT NULL ); diff --git a/src/database/users.rs b/src/database/users.rs new file mode 100644 index 0000000..4569f4e --- /dev/null +++ b/src/database/users.rs @@ -0,0 +1,26 @@ +use super::DataManager; +use oiseau::{get, params, query_rows}; +use tetratto_core::model::{Error, Result}; + +impl DataManager { + /// Search all users. + pub async fn search_users(&self, query: &str, batch: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT username, id FROM users WHERE username LIKE $1 LIMIT $2", + params![&format!("{query}%"), &(batch as i64),], + |x| { get!(x->0(String)) } + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(res.unwrap()) + } +} diff --git a/src/routes/api/auth.rs b/src/routes/api/auth.rs index 276ea38..e0c466a 100644 --- a/src/routes/api/auth.rs +++ b/src/routes/api/auth.rs @@ -189,3 +189,28 @@ pub async fn check_totp_request( payload: Some(!user.totp.is_empty()), }) } + +#[derive(Deserialize)] +pub struct SearchUsers { + pub prefix: String, +} + +pub async fn search_users_request( + jar: CookieJar, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + if get_user_from_token!(jar, data.2).is_none() { + return Json(Error::NotAllowed.into()); + } + + match data.search_users(&props.prefix, 4).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} diff --git a/src/routes/api/chats.rs b/src/routes/api/chats.rs index 294643b..4c15cdf 100644 --- a/src/routes/api/chats.rs +++ b/src/routes/api/chats.rs @@ -22,7 +22,7 @@ use tetratto_core::model::{ApiReturn, Error, auth::User}; #[derive(Deserialize)] pub struct CreateChat { pub style: ChatStyle, - pub members: Vec, + pub members: Vec, } pub async fn create_request( @@ -37,13 +37,23 @@ pub async fn create_request( }; if req.members.len() > 2 && req.style == ChatStyle::Direct { - return Json(Error::DataTooLong("members".to_string()).into()); + return Json(Error::DataTooLong("members list".to_string()).into()); + } else if req.members.len() < 1 { + return Json(Error::DataTooShort("members list".to_string()).into()); } match data .create_chat(Chat::new(req.style, { - let mut x = req.members; + let mut x = Vec::new(); + x.push(user.id); + for y in req.members { + x.push(match data.2.get_user_by_username(&y).await { + Ok(x) => x.id, + Err(e) => return Json(e.into()), + }) + } + x })) .await diff --git a/src/routes/api/mod.rs b/src/routes/api/mod.rs index 63b01fd..cc08846 100644 --- a/src/routes/api/mod.rs +++ b/src/routes/api/mod.rs @@ -14,6 +14,7 @@ pub fn routes() -> Router { "/auth/user/{username}/check_totp", get(auth::check_totp_request), ) + .route("/auth/users/search", post(auth::search_users_request)) // chats .route("/chats", post(chats::create_request)) .route("/chats/{id}/leave", post(chats::leave_request)) diff --git a/src/routes/pages/chats.rs b/src/routes/pages/chats.rs new file mode 100644 index 0000000..65559fc --- /dev/null +++ b/src/routes/pages/chats.rs @@ -0,0 +1,87 @@ +use crate::{ + State, get_user_from_token, + routes::{ + default_context, + pages::{PaginatedQuery, misc::render_error}, + }, +}; +use axum::{ + Extension, + extract::{Path, Query}, + response::{Html, IntoResponse}, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::Error; + +pub async fn list_request( + jar: CookieJar, + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { + let (ref data, ref tera, ref build_code) = *data.read().await; + let user = match get_user_from_token!(jar, data.2) { + Some(x) => x, + None => { + return Err(render_error(Error::NotAllowed, tera, data.0.0.clone(), None).await); + } + }; + + let chats = match data.get_chats_by_member(user.id, 12, props.page).await { + Ok(x) => data.fill_chats(x).await, + Err(e) => { + return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await); + } + }; + + let mut ctx = default_context(&data.0.0, &build_code, &Some(user)); + + ctx.insert("chats", &chats); + ctx.insert("page", &props.page); + + Ok(Html(tera.render("chats.lisp", &ctx).unwrap())) +} + +pub async fn chat_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Query(props): Query, +) -> impl IntoResponse { + let (ref data, ref tera, ref build_code) = *data.read().await; + let user = match get_user_from_token!(jar, data.2) { + Some(x) => x, + None => { + return Err(render_error(Error::NotAllowed, tera, data.0.0.clone(), None).await); + } + }; + + let (chat, members) = match data.get_chat_by_id(id).await { + Ok(x) => { + if !x.members.contains(&user.id) { + return Err( + render_error(Error::NotAllowed, tera, data.0.0.clone(), Some(user)).await, + ); + } + + data.fill_chat(x).await + } + Err(e) => { + return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await); + } + }; + + let messages = match data.get_messages_by_chat(id, 12, props.page).await { + Ok(x) => x, + Err(e) => { + return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await); + } + }; + + let mut ctx = default_context(&data.0.0, &build_code, &Some(user)); + + ctx.insert("chat", &chat); + ctx.insert("members", &members); + ctx.insert("messages", &messages); + + Ok(Html(tera.render("chat.lisp", &ctx).unwrap())) +} diff --git a/src/routes/pages/misc.rs b/src/routes/pages/misc.rs index afca4a1..3a8f5a0 100644 --- a/src/routes/pages/misc.rs +++ b/src/routes/pages/misc.rs @@ -1,24 +1,30 @@ -use crate::{State, get_user_from_token, routes::default_context}; +use crate::{State, config::Config, get_user_from_token, routes::default_context}; use axum::{ Extension, response::{Html, IntoResponse}, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::Error; +use tera::Tera; +use tetratto_core::model::{Error, auth::User}; + +pub async fn render_error( + e: Error, + tera: &Tera, + config: Config, + user: Option, +) -> impl IntoResponse + use<> { + let mut ctx = default_context(&config, "", &user); + ctx.insert("error", &e.to_string()); + return Html(tera.render("error.lisp", &ctx).unwrap()); +} pub async fn not_found_request( jar: CookieJar, Extension(data): Extension, ) -> impl IntoResponse { - let (ref data, ref tera, ref build_code) = *data.read().await; + let (ref data, ref tera, _) = *data.read().await; let user = get_user_from_token!(jar, data.2); - - let mut ctx = default_context(&data.0.0, &build_code, &user); - ctx.insert( - "error", - &Error::GeneralNotFound("page".to_string()).to_string(), - ); - return Html(tera.render("error.lisp", &ctx).unwrap()); + render_error(Error::NotAllowed, tera, data.0.0.clone(), user).await } pub async fn index_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { diff --git a/src/routes/pages/mod.rs b/src/routes/pages/mod.rs index 6fbfa5b..137f792 100644 --- a/src/routes/pages/mod.rs +++ b/src/routes/pages/mod.rs @@ -1,9 +1,21 @@ +pub mod chats; pub mod misc; use axum::routing::{Router, get}; +use serde::Deserialize; pub fn routes() -> Router { Router::new() .route("/", get(misc::index_request)) + // auth .route("/login", get(misc::login_request)) + // chats + .route("/chats", get(chats::list_request)) + .route("/chats/{id}", get(chats::chat_request)) +} + +#[derive(Deserialize)] +pub struct PaginatedQuery { + #[serde(default)] + pub page: usize, }