Система событий

This commit is contained in:
Artem VV 2023-06-20 23:36:01 +07:00
parent cde119f769
commit 1175ccff74
7 changed files with 587 additions and 58 deletions

View file

@ -15,6 +15,34 @@ model news {
created_at DateTime @default(now())
}
model events {
id Int @id @default(autoincrement())
title String
description String
when DateTime
created_at DateTime @default(now())
attendants event_attendance[]
}
enum EventAttendanceType {
NO
YES
YES_ONLINE
}
model event_attendance {
user users @relation(fields: [usersId], references: [id], onDelete: Cascade, onUpdate: Cascade)
event events @relation(fields: [eventsId], references: [id], onDelete: Cascade, onUpdate: Cascade)
type EventAttendanceType
created_at DateTime @default(now())
usersId Int
eventsId Int
@@id([eventsId, usersId])
}
model study_item {
id Int @id(map: "pk_study_item") @default(autoincrement())
title String
@ -54,6 +82,7 @@ model users {
user_session user_session[]
chat_message chat_message[]
user_in_chat user_in_chat[]
attended event_attendance[]
}
model user_session {

View file

@ -1,7 +1,7 @@
---
import { getNewsAndAlerts, getSessionUser } from "../db";
import { getNewsAndAlertsAlsoEvents, getSessionUser, EventAttendanceTypeV } from "../db";
const { news, alerts } = await getNewsAndAlerts();
const { news, alerts, currentEvents } = await getNewsAndAlertsAlsoEvents();
const sessId = Astro.cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
@ -14,21 +14,58 @@ const formatDate = (dt: Date) => {
"." +
dt.getFullYear() +
" в " +
dt.getHours() +
("0" + dt.getHours()).substr(-2) +
":" +
("0" + dt.getMinutes()).substr(-2)
);
};
const attendanceToString = (attt: EventAttendanceTypeV) => {
switch (attt) {
case "NO":
return "не пойду"
case "YES":
return "пойду"
case "YES_ONLINE":
return "пойду онлайн"
}
}
---
<link rel="stylesheet" href="/assets/css/hystmodal.min.css" />
<script is:inline src="/assets/js/hystmodal.min.js"></script>
<div class="hystmodal" id="settingsModal" aria-hidden="true">
<div class="hystmodal__wrap">
<div class="hystmodal__window card p-2" role="dialog" aria-modal="true">
<form onsubmit="createNewEvent(new FormData(this)); return false">
<button data-hystclose class="hystmodal__close" type="reset">Закрыть</button>
<h3>Новое событие</h3>
<label for="title" class="form-label mt-4">Название</label>
<input type="text" id="title" name="title" class="form-control mb-2" required/>
<label for="description" class="form-label">Описание</label>
<input type="text" id="description" name="description" class="form-control mb-2" required/>
<label for="event-time" class="form-label">Описание</label>
<input type="datetime-local" id="event-time" name="event-time" class="form-control" required>
<hr/>
<button type="submit" class="btn btn-sm btn-success w-100">Создать</button>
</form>
</div>
</div>
</div>
<div class="container">
{user.is_admin || user.is_moderator ? (
<a class="btn btn-sm btn-primary d-block mt-2" href="/articleEditor">
Новая статья
</a>
<hr />
) : null}
{
<div class="d-flex flex-row align-items-center mt-2">
<h4>Уведомления</h4>
{user.is_admin || user.is_moderator ? (
<a id="openUsersModalBtn" class="btn btn-sm btn-primary ms-2 material-icons-outlined" style="height: 1.5rem; font-size: 1rem" href="/articleEditor">add</a>
) : null}
</div>
{ alerts.length > 0 ?
alerts.map((article, idx) => (
<div class:list={[idx === 0 ? "mt-2" : ""]}>
<div class="alert alert-danger" role="alert">
@ -45,12 +82,72 @@ const formatDate = (dt: Date) => {
Редактировать
</a>
</div>
) : null}
) : <div class="mt-2">
Уведомления отсутствуют
</div>}
</div>
</div>
))
)) :
<div class="mt-2">
Запланированные события отсутствуют
</div>
}
{ alerts.length > 0 ? <hr /> : null }
<hr />
<div class="d-flex flex-row align-items-center">
<h4>События</h4>
{user.is_admin || user.is_moderator ? (
<button id="openUsersModalBtn" class="btn btn-sm btn-primary ms-2 material-icons-outlined" style="height: 1.5rem; font-size: 1rem" data-hystmodal="#settingsModal">add</button>
) : null}
<a class="btn btn-sm btn-primary ms-2 material-icons-outlined" style="height: 1.5rem; font-size: 1rem" href="/past-events">history</a>
</div>
{ currentEvents.length > 0 ?
currentEvents.map((event, idx) => (
<div class:list={[idx === 0 ? "mt-2" : ""]}>
<div class="alert alert-event" role="alert">
<h4 class="alert-heading">{event.title}</h4>
<h6>{formatDate(event.when)} · {event.attendants.length} посетителей</h6>
<p>{event.description}</p>
<hr />
<div class="d-flex gap-3 ">
{event.attendants.findIndex(el => el.usersId === user.id) === -1 ? (
<button class="btn btn-sm btn-warning flex-grow-1" onclick={`attendEvent(${event.id}, "NO")`}>
Не пойду
</button>
<button class="btn btn-sm btn-success flex-grow-1" onclick={`attendEvent(${event.id}, "YES")`}>
Пойду
</button>
<button class="btn btn-sm btn-primary flex-grow-1" onclick={`attendEvent(${event.id}, "YES_ONLINE")`}>
Пойду онлайн
</button>
) : (
<i>Я <b>{ attendanceToString(event.attendants.find(el => el.usersId === user.id)!.type) }</b> на данное событие</i>
<button class="btn btn-sm btn-primary" onclick={`attendEvent(${event.id}, "UNDO")`}>
Отменить
</button>
)}
</div>
<div class="d-flex gap-3 mt-2">
{user.is_admin || user.is_moderator ? (
<span class="flex-grow-1"></span>
<button class="btn btn-sm btn-danger" onclick={`deleteEvent(${event.id})`}>
Удалить
</button>
) : null}
</div>
</div>
</div>
)) :
<div class="mt-2">
Запланированные события отсутствуют
</div>
}
<hr />
<div class="d-flex flex-row align-items-center">
<h4>Новости</h4>
{user.is_admin || user.is_moderator ? (
<a id="openUsersModalBtn" class="btn btn-sm btn-primary ms-2 material-icons-outlined" style="height: 1.5rem; font-size: 1rem" href="/articleEditor">add</a>
) : null}
</div>
{ news.length > 0 ?
news.map((article, idx) => (
<div class:list={[idx === 0 ? "mt-2" : ""]}>
@ -73,63 +170,123 @@ const formatDate = (dt: Date) => {
</div>
)) :
<div class="mt-2">
<div class="alert alert-success" role="alert">
<h4 class="alert-heading m-0">Новостей нет</h4>
</div>
Новостей нет
</div>
}
</div>
{user.is_admin || user.is_moderator ?
<script is:inline>
async function deleteArticle(articleId) {
try {
const resp = await fetch(`/articles/delete?id=${articleId}`, {
method: "POST"
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason)
}
} catch (e) {
alert(e.message);
}
try {
const resp = await fetch(`/articles/delete?id=${articleId}`, {
method: "POST"
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason)
}
} catch (e) {
alert(e.message);
}
}
async function createNewEvent(formData) {
try {
const resp = await fetch(`/events/create`, {
method: "POST",
body: formData
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason)
}
} catch (e) {
alert(e.message);
}
}
async function deleteEvent(eventId) {
try {
const resp = await fetch(`/events/delete?id=${eventId}`, {
method: "POST"
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason)
}
} catch (e) {
alert(e.message);
}
}
</script> : null}
<script src ="https://cdn.jsdelivr.net/npm/editorjs-html@latest/build/edjsHTML.js"> </script>
<script src ="https://cdn.jsdelivr.net/npm/editorjs-html@latest/build/edjsHTML.js"></script>
<script is:inline>
function checklist(data) {
const items = data.data.items.reduce(
(acc, item) => acc + `<div class="form-check"><input class="form-check-input" type="checkbox"${item.checked ? " checked" : ""} disabled/><label class="form-check-label">${item.text}</label></div>`,
""
);
return `<div>${items}</div>`;
}
function quote(data) {
const {text, caption} = data.data;
if (caption && caption.length > 0) {
return `<figure><blockquote class="blockquote"><p>${text}</p></blockquote><figcaption class="blockquote-footer">${caption}</figcaption></figure>`;
}
return `<blockquote class="blockquote"><p>${text}</p></blockquote>`;
}
function image(data) {
const {file, caption} = data.data;
if (caption && caption.length > 0) {
return `<a href="${file.url}" data-lightbox="${file.url}" data-title="${caption}"><img class="rendered-image" src="${file.url}"/></a>`;
}
return `<a href="${file.url}" data-lightbox="${file.url}"><img class="rendered-image" src="${file.url}"/></a>`;
async function attendEvent(eventId, attt) {
try {
const resp = await fetch(`/events/attend?id=${eventId}&attt=${encodeURIComponent(attt)}`, {
method: "POST"
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason)
}
} catch (e) {
alert(e.message);
}
}
document.addEventListener("DOMContentLoaded", () => {
const edjsParser = edjsHTML({ checklist, quote, image });
document.querySelectorAll("[data-article]").forEach(e => {
e.innerHTML = edjsParser.parse(JSON.parse(e.dataset["article"])).reduce((a,b) => a+b, "");
});
});
function checklist(data) {
const items = data.data.items.reduce(
(acc, item) => acc + `<div class="form-check"><input class="form-check-input" type="checkbox"${item.checked ? " checked" : ""} disabled/><label class="form-check-label">${item.text}</label></div>`,
""
);
return `<div>${items}</div>`;
}
function quote(data) {
const { text, caption } = data.data;
if (caption && caption.length > 0) {
return `<figure><blockquote class="blockquote"><p>${text}</p></blockquote><figcaption class="blockquote-footer">${caption}</figcaption></figure>`;
}
return `<blockquote class="blockquote"><p>${text}</p></blockquote>`;
}
function image(data) {
const { file, caption } = data.data;
if (caption && caption.length > 0) {
return `<a href="${file.url}" data-lightbox="${file.url}" data-title="${caption}"><img class="rendered-image" src="${file.url}"/></a>`;
}
return `<a href="${file.url}" data-lightbox="${file.url}"><img class="rendered-image" src="${file.url}"/></a>`;
}
document.addEventListener("DOMContentLoaded", () => {
const modalsController = new HystModal({
linkAttributeName: "data-hystmodal",
closeOnOverlay: false,
closeOnEsc: false,
});
const edjsParser = edjsHTML({ checklist, quote, image });
document.querySelectorAll("[data-article]").forEach(e => {
e.innerHTML = edjsParser.parse(JSON.parse(e.dataset["article"])).reduce((a, b) => a + b, "");
});
});
</script>
<style is:inline>
img {
max-width: 100%;
}
.alert-event {
--bs-alert-color: hsl(250 61% 21% / 1);
--bs-alert-bg: hsl(250 70% 91% / 1);
--bs-alert-border-color: hsl(250 71% 81% / 1);
--bs-alert-link-color: hsl(250 61% 21% / 1);
}
</style>

View file

@ -1,7 +1,9 @@
import { PrismaClient, chat, chat_message, user_in_chat, users } from "@prisma/client";
import { PrismaClient, chat, chat_message, user_in_chat, users, EventAttendanceType } from "@prisma/client";
import { blake3 } from "@noble/hashes/blake3";
import { bytesToHex as toHex } from "@noble/hashes/utils";
export type EventAttendanceTypeV = EventAttendanceType;
const client = new PrismaClient();
export async function getSessionUser(sessionId: string) {
@ -143,7 +145,7 @@ export async function createSession(user: users) {
return session;
}
export async function getNewsAndAlerts() {
export async function getNewsAndAlertsAlsoEvents() {
const allNews = await client.news.findMany({
orderBy: {
created_at: "desc",
@ -158,9 +160,33 @@ export async function getNewsAndAlerts() {
news.push(n);
}
}
const allEvents = await client.events.findMany({
orderBy: {
created_at: "desc",
},
select: {
id: true,
title: true,
description: true,
when: true,
attendants: true,
},
});
const currentEvents = [];
const pastEvents = [];
const now = new Date();
for (let event of allEvents) {
if (event.when >= now) {
currentEvents.push(event);
} else {
pastEvents.push(event);
}
}
return {
news,
alerts,
currentEvents,
pastEvents,
};
}
@ -533,3 +559,64 @@ export async function deleteChat(chatId: string) {
return chat !== null;
}
export async function deleteEvent(eventId: number) {
const result = await client.events.delete({
where: {
id: eventId,
},
});
return result !== null;
}
export async function createEvent(title: string, description: string, when: Date) {
const article = await client.events.create({
data: {
title: title,
description: description,
when: when,
},
});
return article !== null;
}
export async function attendEvent(eventId: number, userId: number, attendanceType: string) {
if (attendanceType === "UNDO") {
const article = await client.event_attendance.delete({
where: {
eventsId_usersId: {
usersId: userId,
eventsId: eventId,
},
},
});
return article !== null;
} else {
let attt: EventAttendanceType;
switch (attendanceType) {
case "NO":
attt = "NO";
break;
case "YES":
attt = "YES";
break;
case "YES_ONLINE":
attt = "YES_ONLINE";
break;
default:
throw new Error("Неправильный тип");
}
const article = await client.event_attendance.create({
data: {
type: attt,
usersId: userId,
eventsId: eventId,
},
});
return article !== null;
}
}

View file

@ -0,0 +1,34 @@
import type { APIContext } from "astro";
import { attendEvent, getSessionUser } from "../../db";
export async function post({ request, url, cookies }: APIContext) {
const response: { ok: boolean; reason?: string } = {
ok: true,
};
try {
const sessId = cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const eventId = parseInt(url.searchParams.get("id") ?? "");
const attendanceType = url.searchParams.get("attt");
if (!eventId || attendanceType === null) {
throw new Error("Неправильный формат запроса");
}
const attended = await attendEvent(eventId, user.id, attendanceType);
if (!attended) {
throw new Error("Не удалось изменить состояние");
}
} catch (e: any) {
response.ok = false;
if (e instanceof Error) {
response.reason = e.message;
} else {
response.reason = e.toString();
}
}
return {
body: JSON.stringify(response),
};
}

View file

@ -0,0 +1,39 @@
import type { APIContext } from "astro";
import { createEvent, getSessionUser } from "../../db";
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))!;
if (!user.is_admin && !user.is_moderator) {
throw new Error("Доступно только администраторам или модераторам");
}
const fd = await request.formData();
const title = fd.get("title");
const description = fd.get("description");
const eventTime = fd.get("event-time");
if (title === null || description === null || eventTime === null) {
throw new Error("Неправильный формат запроса");
}
const created = await createEvent(title.toString(), description.toString(), new Date(eventTime.toString()));
if (!created) {
throw new Error("Не удалось создать");
}
} catch (e: any) {
response.ok = false;
if (e instanceof Error) {
response.reason = e.message;
} else {
response.reason = e.toString();
}
}
return {
body: JSON.stringify(response),
};
}

View file

@ -0,0 +1,36 @@
import type { APIContext } from "astro";
import { deleteEvent, getSessionUser } from "../../db";
export async function post({ request, url, cookies }: APIContext) {
const response: { ok: boolean; reason?: string } = {
ok: true,
};
try {
const sessId = cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
if (!user.is_admin && !user.is_moderator) {
throw new Error("Доступно только администраторам или модераторам");
}
const eventId = parseInt(url.searchParams.get("id") ?? "");
if (!eventId) {
throw new Error("Неправильный формат запроса");
}
const deleted = await deleteEvent(eventId);
if (!deleted) {
throw new Error("Не удалось удалить");
}
} catch (e: any) {
response.ok = false;
if (e instanceof Error) {
response.reason = e.message;
} else {
response.reason = e.toString();
}
}
return {
body: JSON.stringify(response),
};
}

147
src/pages/past-events.astro Normal file
View file

@ -0,0 +1,147 @@
---
import Layout from "../layouts/Layout.astro";
import { getNewsAndAlertsAlsoEvents, getSessionUser, EventAttendanceTypeV, getUserSession } from "../db";
import NewsBlock from "../components/NewsBlock.astro";
import Navbar from "../components/Navbar.astro";
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 { pastEvents } = await getNewsAndAlertsAlsoEvents();
const sessId = Astro.cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const formatDate = (dt: Date) => {
return (
("0" + dt.getDay()).substr(-2) +
"." +
("0" + (0 + dt.getMonth())).substr(-2) +
"." +
dt.getFullYear() +
" в " +
("0" + dt.getHours()).substr(-2) +
":" +
("0" + dt.getMinutes()).substr(-2)
);
};
const attendanceToString = (attt: EventAttendanceTypeV) => {
switch (attt) {
case "NO":
return "отказался посетить";
case "YES":
return "посетил";
case "YES_ONLINE":
return "посетил онлайн";
}
};
---
<Layout title="Прошедшие события">
<main>
<Navbar is_user_admin={user.is_admin} user={user} />
<div class="container">
{ pastEvents.length > 0 ?
pastEvents.map((event, idx) => (
<div class:list={[idx === 0 ? "mt-4" : ""]}>
<div class="alert alert-event" role="alert">
<h4 class="alert-heading">{event.title}</h4>
<h6>{formatDate(event.when)} · {event.attendants.length} посетителей</h6>
<p>{event.description}</p>
<hr />
<div class="d-flex gap-3 ">
{event.attendants.findIndex(el => el.usersId === user.id) >= 0 ? (
<i>Я <b>{ attendanceToString(event.attendants.find(el => el.usersId === user.id)!.type) }</b> на данное событие</i>
) : (
<i>Я <b>не посещал</b> на данное событие</i>
)}
</div>
<div class="d-flex gap-3 mt-2">
{user.is_admin || user.is_moderator ? (
<span class="flex-grow-1"></span>
<button class="btn btn-sm btn-danger" onclick={`deleteEvent(${event.id})`}>
Удалить
</button>
) : null}
</div>
</div>
</div>
)) :
<div class="mt-2">
Прошедшие события отсутствуют
</div>
}
</div>
</main>
</Layout>
{user.is_admin || user.is_moderator ?
<script is:inline>
async function deleteArticle(articleId) {
try {
const resp = await fetch(`/articles/delete?id=${articleId}`, {
method: "POST"
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason)
}
} catch (e) {
alert(e.message);
}
}
async function createNewEvent(formData) {
try {
const resp = await fetch(`/events/create`, {
method: "POST",
body: formData
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason)
}
} catch (e) {
alert(e.message);
}
}
async function deleteEvent(eventId) {
try {
const resp = await fetch(`/events/delete?id=${eventId}`, {
method: "POST"
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason)
}
} catch (e) {
alert(e.message);
}
}
</script> : null}
<style>
.alert-event {
--bs-alert-color: hsl(250 61% 21% / 1);
--bs-alert-bg: hsl(250 70% 91% / 1);
--bs-alert-border-color: hsl(250 71% 81% / 1);
--bs-alert-link-color: hsl(250 61% 21% / 1);
}
</style>