Добавил систему чатов
This commit is contained in:
parent
be1d03b459
commit
f2a6469a55
10 changed files with 632 additions and 7 deletions
31
src/components/ChatList.astro
Normal file
31
src/components/ChatList.astro
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
import type { users, timetable } from "@prisma/client";
|
||||
import { getUserChats } from "../db";
|
||||
|
||||
export interface Props {
|
||||
user: users;
|
||||
}
|
||||
|
||||
const { user } = Astro.props;
|
||||
|
||||
const chats = await getUserChats(user.id);
|
||||
---
|
||||
|
||||
<div class="col-4 border-end">
|
||||
<ol class="list-group list-group-flush">
|
||||
<a class="list-group-item d-flex justify-content-between align-items-start" role="button" href="/newChat">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">+ Создать чат</div>
|
||||
</div>
|
||||
</a>
|
||||
{
|
||||
chats.map((e) => (
|
||||
<a class="list-group-item d-flex justify-content-between align-items-start" role="button" href={`/chats?openChat=${e.id}`}>
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">{e.title}</div>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
195
src/components/ChatView.astro
Normal file
195
src/components/ChatView.astro
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
---
|
||||
import type { users } from "@prisma/client";
|
||||
import type { CompositeChat } from "../db";
|
||||
|
||||
export interface Props {
|
||||
user: users;
|
||||
chat: CompositeChat;
|
||||
}
|
||||
|
||||
const { user, chat } = Astro.props;
|
||||
|
||||
const formatDate = (dt: Date) => {
|
||||
return (
|
||||
("0" + dt.getDay()).substr(-2) +
|
||||
"." +
|
||||
("0" + (0 + dt.getMonth())).substr(-2) +
|
||||
"." +
|
||||
dt.getFullYear() +
|
||||
" в " +
|
||||
dt.getHours() +
|
||||
":" +
|
||||
("0" + dt.getMinutes()).substr(-2)
|
||||
);
|
||||
};
|
||||
|
||||
let lastMessageAt: Date | null = null;
|
||||
if (chat.messages.length > 0) {
|
||||
lastMessageAt = chat.messages[chat.messages.length - 1].sendAt;
|
||||
lastMessageAt.setSeconds(lastMessageAt.getSeconds() + 1);
|
||||
}
|
||||
---
|
||||
|
||||
<div class="col d-flex flex-column" data-chatId={chat.chat.id}>
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="#">{chat.chat.title}</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div id="chatHolder" class="flex-grow-1" data-lastMessageAt={lastMessageAt}>
|
||||
{
|
||||
chat.messages.map((e) => (
|
||||
<div class="list-group m-1" data-messageId={e.id}>
|
||||
<div class="list-group-item list-group-item-action">
|
||||
<p class="mb-1">{e.text}</p>
|
||||
<small class="text-muted">
|
||||
{e.user.fullName ?? e.user.login} • {formatDate(e.sendAt)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="container-fluid bg-light p-4 d-flex flex-row gap-2">
|
||||
<input type="text" class="form-control" id="messageText" placeholder="Введите сообщение" />
|
||||
<button type="button" class="btn btn-primary" id="sendMessage">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import type { CompositeChat } from "../db";
|
||||
|
||||
const attr = (attrName: string) => document.querySelector(`[${attrName}]`)!.getAttribute(attrName)!;
|
||||
const chatId = () => attr("data-chatId");
|
||||
const lastMessageAt = () => new Date(attr("data-lastMessageAt"));
|
||||
|
||||
const formatDate = (dt: Date) => {
|
||||
return (
|
||||
("0" + dt.getDay()).substr(-2) +
|
||||
"." +
|
||||
("0" + (0 + dt.getMonth())).substr(-2) +
|
||||
"." +
|
||||
dt.getFullYear() +
|
||||
" в " +
|
||||
dt.getHours() +
|
||||
":" +
|
||||
("0" + dt.getMinutes()).substr(-2)
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessageTemplate = (messageId: string, text: string, sender: string, sendAt: Date) => `<div class="list-group m-1" data-messageId=${messageId}>
|
||||
<div class="list-group-item list-group-item-action">
|
||||
<p class="mb-1">${text}</p>
|
||||
<small class="text-muted">
|
||||
${sender} • ${formatDate(sendAt)}
|
||||
</small>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
async function renderMessages(data: CompositeChat) {
|
||||
const chatHolder = document.getElementById("chatHolder")!;
|
||||
|
||||
const lastMessage = chatHolder.querySelectorAll(`[data-messageId]`).item(chatHolder.childElementCount - 1);
|
||||
const lastMessageId = lastMessage !== null ? lastMessage.getAttribute("data-messageId") : null;
|
||||
|
||||
for (const message of data.messages) {
|
||||
if (lastMessageId !== null && message.id === lastMessageId) continue;
|
||||
const sender = message.user.fullName ?? message.user.login;
|
||||
const messageHtml = renderMessageTemplate(message.id, message.text, sender, new Date(message.sendAt));
|
||||
chatHolder.innerHTML += messageHtml;
|
||||
}
|
||||
|
||||
chatHolder.scrollTop = chatHolder.scrollHeight;
|
||||
|
||||
const lastMessageInPayload = data.messages[data.messages.length - 1];
|
||||
if (lastMessageInPayload === undefined || lastMessageInPayload === null) return;
|
||||
|
||||
const lastMessageAt = new Date(lastMessageInPayload.sendAt);
|
||||
chatHolder.setAttribute("data-lastMessageAt", lastMessageAt.toISOString());
|
||||
}
|
||||
|
||||
async function fetchMessages() {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("chatId", chatId());
|
||||
fd.append("since", lastMessageAt().toISOString());
|
||||
|
||||
const resp = await fetch("/chatapi/getMessages", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.ok) {
|
||||
const messagesData: CompositeChat = json.data;
|
||||
renderMessages(messagesData);
|
||||
} else {
|
||||
throw new Error(json.reason);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error) {
|
||||
alert(`Ошибка загрузки сообщений: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(message: string) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("chatId", chatId());
|
||||
fd.append("message", message);
|
||||
|
||||
const resp = await fetch("/chatapi/sendMessage", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.ok) {
|
||||
fetchMessages();
|
||||
} else {
|
||||
throw new Error(json.reason);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error) {
|
||||
alert(`Ошибка отправки сообщения: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const messageText = document.getElementById("messageText")! as HTMLInputElement;
|
||||
const sendMessageBtn = document.getElementById("sendMessage")!;
|
||||
|
||||
sendMessageBtn.addEventListener("click", () => {
|
||||
const text = messageText.value;
|
||||
if (text.length === 0) return;
|
||||
|
||||
messageText.value = "";
|
||||
messageText.focus();
|
||||
|
||||
sendMessage(text);
|
||||
});
|
||||
|
||||
messageText.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
sendMessageBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
const chatHolder = document.getElementById("chatHolder")!;
|
||||
chatHolder.scrollTop = chatHolder.scrollHeight;
|
||||
|
||||
setInterval(() => {
|
||||
fetchMessages();
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#chatHolder {
|
||||
max-height: calc(100vh - 13rem);
|
||||
overflow: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -18,6 +18,10 @@ const items = [
|
|||
href: "/users",
|
||||
title: "Пользователи",
|
||||
},
|
||||
{
|
||||
href: "/chats",
|
||||
title: "Чаты",
|
||||
},
|
||||
];
|
||||
|
||||
const itemsRight = [
|
||||
|
|
|
|||
106
src/db.ts
106
src/db.ts
|
|
@ -1,4 +1,4 @@
|
|||
import { PrismaClient, users } from "@prisma/client";
|
||||
import { PrismaClient, chat, chat_message, users } from "@prisma/client";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import { bytesToHex as toHex } from "@noble/hashes/utils";
|
||||
|
||||
|
|
@ -371,3 +371,107 @@ export async function updateUserTimetable(userLogin: string, oddUpdate: UpdateTT
|
|||
.reduce((acc, val) => acc.concat(val), []),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserChats(userId: number) {
|
||||
const chats = await client.user_in_chat.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
include: {
|
||||
chat: true,
|
||||
},
|
||||
});
|
||||
return chats.map((x) => x.chat);
|
||||
}
|
||||
|
||||
export type CompositeChat = {
|
||||
chat: chat;
|
||||
messages: (chat_message & {
|
||||
user: users;
|
||||
})[];
|
||||
};
|
||||
|
||||
export async function getChatMessages(chatId: string, since?: Date): Promise<CompositeChat> {
|
||||
const chat = (await client.chat.findFirst({
|
||||
where: {
|
||||
id: chatId,
|
||||
},
|
||||
}))!;
|
||||
const messages = await client.chat_message.findMany({
|
||||
where: {
|
||||
chatId: chatId,
|
||||
sendAt: {
|
||||
gt: since,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
orderBy: {
|
||||
sendAt: "asc",
|
||||
},
|
||||
});
|
||||
return {
|
||||
chat,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createChatMessage(chatId: string, userId: number, message: string) {
|
||||
const msg = await client.chat_message.create({
|
||||
data: {
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
text: message,
|
||||
},
|
||||
});
|
||||
return msg;
|
||||
}
|
||||
|
||||
export async function createChat(title: string, userIds: number[]) {
|
||||
const chat = await client.chat.create({
|
||||
data: {
|
||||
title: title,
|
||||
user_in_chat: {
|
||||
createMany: {
|
||||
data: userIds.map((x) => {
|
||||
return {
|
||||
userId: x,
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return chat;
|
||||
}
|
||||
|
||||
export async function userAllowedToChat(userId: number, chatId: string) {
|
||||
const chat = await client.user_in_chat.findFirst({
|
||||
where: {
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
return chat !== null;
|
||||
}
|
||||
|
||||
export async function addUserToChat(userId: number, chatId: string) {
|
||||
const chat = await client.user_in_chat.create({
|
||||
data: {
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
return chat !== null;
|
||||
}
|
||||
|
||||
export async function removeUserFromChat(userId: number, chatId: string) {
|
||||
const chat = await client.user_in_chat.deleteMany({
|
||||
where: {
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
return chat !== null;
|
||||
}
|
||||
|
|
|
|||
37
src/pages/chatapi/createChat.ts
Normal file
37
src/pages/chatapi/createChat.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { APIContext } from "astro";
|
||||
import { getSessionUser, createChat } from "../../db";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export async function post({ request, cookies }: APIContext) {
|
||||
const response: { ok: boolean; reason?: string } = {
|
||||
ok: true,
|
||||
};
|
||||
try {
|
||||
const sessId = cookies.get("session").value!;
|
||||
const user = (await getSessionUser(sessId))!;
|
||||
|
||||
const formData = await request.formData();
|
||||
const title = formData.get("title");
|
||||
const selectedUsers = formData.getAll("selectedUsers").map((x) => parseInt(x.toString()));
|
||||
if (title === null || selectedUsers.length === 0 || selectedUsers.find((x) => isNaN(x))) {
|
||||
throw new Error("Предоставлены некорректные данные для создания чата");
|
||||
}
|
||||
|
||||
await createChat(title.toString(), [...selectedUsers, user.id]);
|
||||
|
||||
response.ok = true;
|
||||
} catch (e: any) {
|
||||
response.ok = false;
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
response.reason = `Неизвестная ошибка базы данных. Код ${e.code}`;
|
||||
} else if (e instanceof Error) {
|
||||
response.reason = e.message.trim();
|
||||
} else {
|
||||
response.reason = e.toString().trim();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
body: JSON.stringify(response),
|
||||
};
|
||||
}
|
||||
43
src/pages/chatapi/getMessages.ts
Normal file
43
src/pages/chatapi/getMessages.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { APIContext } from "astro";
|
||||
import { getSessionUser, userAllowedToChat, getChatMessages, CompositeChat } from "../../db";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export async function post({ request, cookies }: APIContext) {
|
||||
const response: { ok: boolean; reason?: string; data?: CompositeChat } = {
|
||||
ok: true,
|
||||
};
|
||||
try {
|
||||
const sessId = cookies.get("session").value!;
|
||||
const user = (await getSessionUser(sessId))!;
|
||||
|
||||
const formData = await request.formData();
|
||||
const chatId = formData.get("chatId");
|
||||
const since = formData.get("since");
|
||||
if (chatId === null) {
|
||||
throw new Error("Не предоставлены данные получения сообщений");
|
||||
}
|
||||
|
||||
const userAllowed = await userAllowedToChat(user.id, chatId.toString());
|
||||
if (!userAllowed) {
|
||||
throw new Error("Нет доступа к чату");
|
||||
}
|
||||
|
||||
const messages = await getChatMessages(chatId.toString(), since === null ? undefined : new Date(since.toString()));
|
||||
|
||||
response.ok = true;
|
||||
response.data = messages;
|
||||
} catch (e: any) {
|
||||
response.ok = false;
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
response.reason = `Неизвестная ошибка базы данных. Код ${e.code}`;
|
||||
} else if (e instanceof Error) {
|
||||
response.reason = e.message.trim();
|
||||
} else {
|
||||
response.reason = e.toString().trim();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
body: JSON.stringify(response),
|
||||
};
|
||||
}
|
||||
42
src/pages/chatapi/sendMessage.ts
Normal file
42
src/pages/chatapi/sendMessage.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { APIContext } from "astro";
|
||||
import { getSessionUser, userAllowedToChat, createChatMessage } from "../../db";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export async function post({ request, cookies }: APIContext) {
|
||||
const response: { ok: boolean; reason?: string } = {
|
||||
ok: true,
|
||||
};
|
||||
try {
|
||||
const sessId = cookies.get("session").value!;
|
||||
const user = (await getSessionUser(sessId))!;
|
||||
|
||||
const formData = await request.formData();
|
||||
const chatId = formData.get("chatId");
|
||||
const message = formData.get("message");
|
||||
if (chatId === null || message === null) {
|
||||
throw new Error("Не предоставлены данные отправки сообщения");
|
||||
}
|
||||
|
||||
const userAllowed = await userAllowedToChat(user.id, chatId.toString());
|
||||
if (!userAllowed) {
|
||||
throw new Error("Нет доступа к чату");
|
||||
}
|
||||
|
||||
await createChatMessage(chatId.toString(), user.id, message.toString());
|
||||
|
||||
response.ok = true;
|
||||
} catch (e: any) {
|
||||
response.ok = false;
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
response.reason = `Неизвестная ошибка базы данных. Код ${e.code}`;
|
||||
} else if (e instanceof Error) {
|
||||
response.reason = e.message.trim();
|
||||
} else {
|
||||
response.reason = e.toString().trim();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
body: JSON.stringify(response),
|
||||
};
|
||||
}
|
||||
48
src/pages/chats.astro
Normal file
48
src/pages/chats.astro
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Navbar from "../components/Navbar.astro";
|
||||
import ChatList from "../components/ChatList.astro";
|
||||
import ChatView from "../components/ChatView.astro";
|
||||
|
||||
import { getUserSession, getSessionUser, CompositeChat, getChatMessages } from "../db";
|
||||
import type { chat, chat_message, users } from "@prisma/client";
|
||||
|
||||
if (Astro.cookies.has("session")) {
|
||||
const sessId = Astro.cookies.get("session").value!;
|
||||
const dbSess = await getUserSession(sessId);
|
||||
if (dbSess === null) {
|
||||
Astro.cookies.delete("session");
|
||||
return Astro.redirect("/login");
|
||||
}
|
||||
} else {
|
||||
return Astro.redirect("/login");
|
||||
}
|
||||
|
||||
let openChat: CompositeChat | null = null;
|
||||
if (Astro.url.searchParams.has("openChat")) {
|
||||
let openChatId = Astro.url.searchParams.get("openChat")!;
|
||||
openChat = await getChatMessages(openChatId);
|
||||
}
|
||||
|
||||
const sessId = Astro.cookies.get("session").value!;
|
||||
const user = (await getSessionUser(sessId))!;
|
||||
---
|
||||
|
||||
<Layout title="Чаты">
|
||||
<main class="d-flex flex-column" style="min-height: 100vh;">
|
||||
<Navbar is_user_admin={user.is_admin} />
|
||||
<div class="flex-grow-1 container-fluid m-0 p-0 d-flex flex-row">
|
||||
<ChatList user={user} />
|
||||
{openChat !== null && <ChatView user={user} chat={openChat} />}
|
||||
{
|
||||
openChat === null && (
|
||||
<div class="col flex-grow-1 d-flex text-center align-items-stretch align-self-center">
|
||||
<div class="alert alert-info w-100 m-5" role="alert">
|
||||
Выберите чат из списка слева
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
88
src/pages/newChat.astro
Normal file
88
src/pages/newChat.astro
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Navbar from "../components/Navbar.astro";
|
||||
|
||||
import { getUserSession, getSessionUser, searchUsers } from "../db";
|
||||
|
||||
if (Astro.cookies.has("session")) {
|
||||
const sessId = Astro.cookies.get("session").value!;
|
||||
const dbSess = await getUserSession(sessId);
|
||||
if (dbSess === null) {
|
||||
Astro.cookies.delete("session");
|
||||
return Astro.redirect("/login");
|
||||
}
|
||||
} else {
|
||||
return Astro.redirect("/login");
|
||||
}
|
||||
|
||||
const sessId = Astro.cookies.get("session").value!;
|
||||
const user = (await getSessionUser(sessId))!;
|
||||
|
||||
const users = (await searchUsers({})).filter((e) => e.id !== user.id);
|
||||
---
|
||||
|
||||
<Layout title="Новый чат">
|
||||
<main>
|
||||
<Navbar is_user_admin={user.is_admin} />
|
||||
<div class="container mt-5">
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label for="chatTitle" class="form-label">Название чата</label>
|
||||
<input type="text" class="form-control" id="chatTitle" name="title" />
|
||||
</div>
|
||||
<label for="chatTitle" class="form-label">Кто участвует в чате:</label>
|
||||
<div class="d-flex flex-wrap flex-row">
|
||||
{
|
||||
users.map((e) => (
|
||||
<div class="mb-3 form-check col-2">
|
||||
<input type="checkbox" class="form-check-input" id={`userCheck-${e.login}`} name="selectedUsers" value={e.id} />
|
||||
<label class="form-check-label" for={`userCheck-${e.login}`}>
|
||||
{e.login}
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Создать</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
async function createChat(form: HTMLFormElement) {
|
||||
try {
|
||||
const fd = new FormData(form);
|
||||
if (fd.get("title")?.length === 0) {
|
||||
throw new Error("Не указано название чата");
|
||||
}
|
||||
if (fd.getAll("selectedUsers").length === 0) {
|
||||
throw new Error("Не выбраны пользователи");
|
||||
}
|
||||
|
||||
const resp = await fetch("/chatapi/createChat", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.ok) {
|
||||
alert("Чат создан");
|
||||
window.location.href = "/chats";
|
||||
} else {
|
||||
throw new Error(json.reason);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error) alert(e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.querySelector("form");
|
||||
form?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
createChat(form);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
Loading…
Add table
Add a link
Reference in a new issue