generated from t/malachite
add: chats list page
This commit is contained in:
parent
c48cf78314
commit
747a05d649
16 changed files with 576 additions and 24 deletions
|
@ -17,6 +17,10 @@
|
||||||
--color-green-lowered: hsl(100, 84%, 15%);
|
--color-green-lowered: hsl(100, 84%, 15%);
|
||||||
--color-red-lowered: hsl(0, 84%, 35%);
|
--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-x-offset: 0;
|
||||||
--shadow-y-offset: 0.125rem;
|
--shadow-y-offset: 0.125rem;
|
||||||
--shadow-size: var(--pad-1);
|
--shadow-size: var(--pad-1);
|
||||||
|
@ -51,6 +55,10 @@
|
||||||
--color-green: hsl(100, 94%, 82%);
|
--color-green: hsl(100, 94%, 82%);
|
||||||
--color-yellow: oklch(90.1% 0.076 70.697);
|
--color-yellow: oklch(90.1% 0.076 70.697);
|
||||||
--color-purple: hsl(284, 94%, 82%);
|
--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,
|
html,
|
||||||
|
@ -94,7 +102,7 @@ article {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs .tab {
|
.tabs:not(.short) .tab {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -195,6 +203,10 @@ video {
|
||||||
padding: var(--pad-2) var(--pad-4);
|
padding: var(--pad-2) var(--pad-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card.surface {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
/* button */
|
/* button */
|
||||||
.button {
|
.button {
|
||||||
--h: 36px;
|
--h: 36px;
|
||||||
|
@ -215,6 +227,7 @@ video {
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:disabled {
|
.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 */
|
||||||
.dropdown {
|
.dropdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -468,6 +490,12 @@ svg.icon {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.big_icon svg.icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
button svg {
|
button svg {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
@ -621,6 +649,10 @@ span {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gap_ch {
|
||||||
|
gap: 1ch;
|
||||||
|
}
|
||||||
|
|
||||||
/* table */
|
/* table */
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -675,7 +707,7 @@ dialog {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.inner {
|
dialog .inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--pad-2);
|
gap: var(--pad-2);
|
||||||
|
|
26
app/templates_src/chat.lisp
Normal file
26
app/templates_src/chat.lisp
Normal file
|
@ -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 %}")
|
217
app/templates_src/chats.lisp
Normal file
217
app/templates_src/chats.lisp
Normal file
|
@ -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 += `<li>${member} (<a href=\"javascript:remove_member('${member}')\" class=\"red\">remove</a>)</li>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 += `<option value=\"${username}\">${username}</option>`;
|
||||||
|
}
|
||||||
|
} 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 %}")
|
|
@ -7,3 +7,36 @@
|
||||||
("loading" "lazy")
|
("loading" "lazy")
|
||||||
("style" "--size: {{ size }}"))
|
("style" "--size: {{ size }}"))
|
||||||
(text "{%- endmacro %}")
|
(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 %}")
|
||||||
|
|
|
@ -10,8 +10,6 @@
|
||||||
(link ("rel" "stylesheet") ("href" "https://repodelivery.tetratto.com/tetratto/crates/app/src/public/css/utility.css"))
|
(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 }}"))
|
(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 ("name" "theme-color") ("content" "{{ theme_color }}"))
|
||||||
(meta ("property" "og:type") ("content" "website"))
|
(meta ("property" "og:type") ("content" "website"))
|
||||||
(meta ("property" "og:site_name") ("content" "{{ name }}"))
|
(meta ("property" "og:site_name") ("content" "{{ name }}"))
|
||||||
|
@ -28,7 +26,7 @@
|
||||||
(body
|
(body
|
||||||
; nav
|
; nav
|
||||||
(nav
|
(nav
|
||||||
("class" "flex w_full justify_between gap_2 sticky")
|
("class" "flex w_full justify_between gap_2")
|
||||||
(div
|
(div
|
||||||
("class" "flex side")
|
("class" "flex side")
|
||||||
(div
|
(div
|
||||||
|
@ -76,8 +74,7 @@
|
||||||
("onclick" "user_logout()")
|
("onclick" "user_logout()")
|
||||||
(text "logout"))
|
(text "logout"))
|
||||||
(text "{%- endif %}")
|
(text "{%- endif %}")
|
||||||
(text "{% block dropdown %}{% endblock %}")))
|
(text "{% block dropdown %}{% endblock %}"))))
|
||||||
(a ("class" "button camo") ("href" "/") (b (text "{{ name }}"))))
|
|
||||||
|
|
||||||
(div
|
(div
|
||||||
("class" "side flex")
|
("class" "side flex")
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
use super::DataManager;
|
use super::DataManager;
|
||||||
use crate::model::{Chat, ChatStyle};
|
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::{
|
use tetratto_core::{
|
||||||
auto_method,
|
auto_method,
|
||||||
model::{Error, Result},
|
model::{Error, Result, auth::User},
|
||||||
};
|
};
|
||||||
|
|
||||||
impl DataManager {
|
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:{}");
|
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<User>) {
|
||||||
|
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<Chat>) -> Vec<(Chat, Vec<User>)> {
|
||||||
|
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<Vec<Chat>> {
|
||||||
|
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.
|
/// Create a new chat in the database.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|
|
@ -3,7 +3,7 @@ use crate::model::{Message, SocketMessage, SocketMethod};
|
||||||
use oiseau::{
|
use oiseau::{
|
||||||
PostgresRow,
|
PostgresRow,
|
||||||
cache::{Cache, redis::Commands},
|
cache::{Cache, redis::Commands},
|
||||||
execute, get, params,
|
execute, get, params, query_rows,
|
||||||
};
|
};
|
||||||
use tetratto_core::{
|
use tetratto_core::{
|
||||||
auto_method,
|
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:{}");
|
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<Vec<Message>> {
|
||||||
|
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.
|
/// Create a new message in the database.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
mod chats;
|
mod chats;
|
||||||
mod messages;
|
mod messages;
|
||||||
mod sql;
|
mod sql;
|
||||||
|
mod users;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use buckets_core::{Config as BucketsConfig, DataManager as BucketsManager};
|
use buckets_core::{Config as BucketsConfig, DataManager as BucketsManager};
|
||||||
|
|
|
@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS t_messages (
|
||||||
created BIGINT NOT NULL,
|
created BIGINT NOT NULL,
|
||||||
edited BIGINT NOT NULL,
|
edited BIGINT NOT NULL,
|
||||||
owner BIGINT NOT NULL,
|
owner BIGINT NOT NULL,
|
||||||
chats BIGINT NOT NULL,
|
chat BIGINT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
uploads TEXT NOT NULL
|
uploads TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
26
src/database/users.rs
Normal file
26
src/database/users.rs
Normal file
|
@ -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<Vec<String>> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -189,3 +189,28 @@ pub async fn check_totp_request(
|
||||||
payload: Some(!user.totp.is_empty()),
|
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<State>,
|
||||||
|
Json(props): Json<SearchUsers>,
|
||||||
|
) -> 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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -22,7 +22,7 @@ use tetratto_core::model::{ApiReturn, Error, auth::User};
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateChat {
|
pub struct CreateChat {
|
||||||
pub style: ChatStyle,
|
pub style: ChatStyle,
|
||||||
pub members: Vec<usize>,
|
pub members: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_request(
|
pub async fn create_request(
|
||||||
|
@ -37,13 +37,23 @@ pub async fn create_request(
|
||||||
};
|
};
|
||||||
|
|
||||||
if req.members.len() > 2 && req.style == ChatStyle::Direct {
|
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
|
match data
|
||||||
.create_chat(Chat::new(req.style, {
|
.create_chat(Chat::new(req.style, {
|
||||||
let mut x = req.members;
|
let mut x = Vec::new();
|
||||||
|
|
||||||
x.push(user.id);
|
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
|
x
|
||||||
}))
|
}))
|
||||||
.await
|
.await
|
||||||
|
|
|
@ -14,6 +14,7 @@ pub fn routes() -> Router {
|
||||||
"/auth/user/{username}/check_totp",
|
"/auth/user/{username}/check_totp",
|
||||||
get(auth::check_totp_request),
|
get(auth::check_totp_request),
|
||||||
)
|
)
|
||||||
|
.route("/auth/users/search", post(auth::search_users_request))
|
||||||
// chats
|
// chats
|
||||||
.route("/chats", post(chats::create_request))
|
.route("/chats", post(chats::create_request))
|
||||||
.route("/chats/{id}/leave", post(chats::leave_request))
|
.route("/chats/{id}/leave", post(chats::leave_request))
|
||||||
|
|
87
src/routes/pages/chats.rs
Normal file
87
src/routes/pages/chats.rs
Normal file
|
@ -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<State>,
|
||||||
|
Query(props): Query<PaginatedQuery>,
|
||||||
|
) -> 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<State>,
|
||||||
|
Path(id): Path<usize>,
|
||||||
|
Query(props): Query<PaginatedQuery>,
|
||||||
|
) -> 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()))
|
||||||
|
}
|
|
@ -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::{
|
use axum::{
|
||||||
Extension,
|
Extension,
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
};
|
};
|
||||||
use axum_extra::extract::CookieJar;
|
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<User>,
|
||||||
|
) -> 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(
|
pub async fn not_found_request(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Extension(data): Extension<State>,
|
Extension(data): Extension<State>,
|
||||||
) -> impl IntoResponse {
|
) -> 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 user = get_user_from_token!(jar, data.2);
|
||||||
|
render_error(Error::NotAllowed, tera, data.0.0.clone(), user).await
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||||
|
|
|
@ -1,9 +1,21 @@
|
||||||
|
pub mod chats;
|
||||||
pub mod misc;
|
pub mod misc;
|
||||||
|
|
||||||
use axum::routing::{Router, get};
|
use axum::routing::{Router, get};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
pub fn routes() -> Router {
|
pub fn routes() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(misc::index_request))
|
.route("/", get(misc::index_request))
|
||||||
|
// auth
|
||||||
.route("/login", get(misc::login_request))
|
.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,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue