Добавил модераторскую роль
This commit is contained in:
parent
fb6fb58d11
commit
3e1cfefdd9
15 changed files with 141 additions and 48 deletions
|
|
@ -42,12 +42,13 @@ model timetable {
|
||||||
}
|
}
|
||||||
|
|
||||||
model users {
|
model users {
|
||||||
id Int @id(map: "pk_users") @default(autoincrement())
|
id Int @id(map: "pk_users") @default(autoincrement())
|
||||||
login String @unique @db.VarChar(25)
|
login String @unique @db.VarChar(25)
|
||||||
pass String @db.VarChar(100)
|
pass String @db.VarChar(100)
|
||||||
fullName String? @db.VarChar(100)
|
fullName String? @db.VarChar(100)
|
||||||
is_admin Boolean @default(false)
|
is_admin Boolean @default(false)
|
||||||
lore String? @db.Text()
|
is_moderator Boolean @default(false)
|
||||||
|
lore String? @db.Text()
|
||||||
|
|
||||||
timetable timetable[]
|
timetable timetable[]
|
||||||
user_session user_session[]
|
user_session user_session[]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
---
|
---
|
||||||
|
import type { users } from "@prisma/client";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
is_user_admin: boolean;
|
is_user_admin: boolean;
|
||||||
|
user: users;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { is_user_admin } = Astro.props;
|
const { is_user_admin, user } = Astro.props;
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
|
|
@ -25,6 +28,10 @@ const items = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const itemsRight = [
|
const itemsRight = [
|
||||||
|
{
|
||||||
|
href: `/user/${user.login}`,
|
||||||
|
title: "Профиль",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/logout",
|
href: "/logout",
|
||||||
title: "Выйти",
|
title: "Выйти",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const formatDate = (dt: Date) => {
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{user.is_admin ? (
|
{user.is_admin || user.is_moderator ? (
|
||||||
<a class="btn btn-sm btn-primary d-block mt-2" href="/articleEditor">
|
<a class="btn btn-sm btn-primary d-block mt-2" href="/articleEditor">
|
||||||
Новая статья
|
Новая статья
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -35,7 +35,7 @@ const formatDate = (dt: Date) => {
|
||||||
<h4 class="alert-heading">{article.title}</h4>
|
<h4 class="alert-heading">{article.title}</h4>
|
||||||
<h6>{formatDate(article.created_at)}</h6>
|
<h6>{formatDate(article.created_at)}</h6>
|
||||||
<p data-article={article.message} />
|
<p data-article={article.message} />
|
||||||
{user.is_admin ? (
|
{user.is_admin || user.is_moderator ? (
|
||||||
<hr />
|
<hr />
|
||||||
<div class="d-flex gap-3">
|
<div class="d-flex gap-3">
|
||||||
<button class="btn btn-sm btn-danger flex-grow-1" onclick={`deleteArticle(${article.id})`}>
|
<button class="btn btn-sm btn-danger flex-grow-1" onclick={`deleteArticle(${article.id})`}>
|
||||||
|
|
@ -58,7 +58,7 @@ const formatDate = (dt: Date) => {
|
||||||
<h4 class="alert-heading">{article.title}</h4>
|
<h4 class="alert-heading">{article.title}</h4>
|
||||||
<h6>{formatDate(article.created_at)}</h6>
|
<h6>{formatDate(article.created_at)}</h6>
|
||||||
<p data-article={article.message} />
|
<p data-article={article.message} />
|
||||||
{user.is_admin ? (
|
{user.is_admin || user.is_moderator ? (
|
||||||
<hr />
|
<hr />
|
||||||
<div class="d-flex gap-3">
|
<div class="d-flex gap-3">
|
||||||
<button class="btn btn-sm btn-danger flex-grow-1" onclick={`deleteArticle(${article.id})`}>
|
<button class="btn btn-sm btn-danger flex-grow-1" onclick={`deleteArticle(${article.id})`}>
|
||||||
|
|
@ -79,7 +79,7 @@ const formatDate = (dt: Date) => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{user.is_admin ?
|
{user.is_admin || user.is_moderator ?
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
async function deleteArticle(articleId) {
|
async function deleteArticle(articleId) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
12
src/db.ts
12
src/db.ts
|
|
@ -113,6 +113,18 @@ export async function updateUserLore(login: string, newLore: string) {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function makeUserModerator(login: string) {
|
||||||
|
const user = await client.users.update({
|
||||||
|
where: {
|
||||||
|
login: login,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
is_moderator: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteUser(login: string) {
|
export async function deleteUser(login: string) {
|
||||||
const user = await client.users.delete({
|
const user = await client.users.delete({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import type { news } from "@prisma/client";
|
||||||
const sessId = Astro.cookies.get("session").value!;
|
const sessId = Astro.cookies.get("session").value!;
|
||||||
const user = await getSessionUser(sessId);
|
const user = await getSessionUser(sessId);
|
||||||
|
|
||||||
if (user === null || !user.is_admin) {
|
if (user === null || (!user.is_admin && !user.is_moderator)) {
|
||||||
return Astro.redirect("/login");
|
return Astro.redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ if (articleId) {
|
||||||
|
|
||||||
<Layout title="Создание новости">
|
<Layout title="Создание новости">
|
||||||
<main data-article={article?.message} data-articleId={articleId}>
|
<main data-article={article?.message} data-articleId={articleId}>
|
||||||
<Navbar is_user_admin={user.is_admin} />
|
<Navbar is_user_admin={user.is_admin} user={user} />
|
||||||
<div class="container mt-5" style="max-width: 650px;">
|
<div class="container mt-5" style="max-width: 650px;">
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<button type="button" class="btn btn-sm btn-success flex-fill" id="saveBtn">Сохранить</button>
|
<button type="button" class="btn btn-sm btn-success flex-fill" id="saveBtn">Сохранить</button>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import type { APIContext } from "astro";
|
import type { APIContext } from "astro";
|
||||||
import { createArticle, updateArticle } from "../../db";
|
import { createArticle, updateArticle, getSessionUser } from "../../db";
|
||||||
|
|
||||||
export async function post({ request, url }: APIContext) {
|
export async function post({ request, cookies }: APIContext) {
|
||||||
const response: { ok: boolean; reason?: string } = {
|
const response: { ok: boolean; reason?: string } = {
|
||||||
ok: true,
|
ok: true,
|
||||||
};
|
};
|
||||||
try {
|
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 fd = await request.formData();
|
||||||
const title = fd.get("title");
|
const title = fd.get("title");
|
||||||
const data = fd.get("data");
|
const data = fd.get("data");
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import type { APIContext } from "astro";
|
import type { APIContext } from "astro";
|
||||||
import { deleteArticle } from "../../db";
|
import { deleteArticle, getSessionUser } from "../../db";
|
||||||
|
|
||||||
export async function post({ request, url }: APIContext) {
|
export async function post({ request, url, cookies }: APIContext) {
|
||||||
const response: { ok: boolean; reason?: string } = {
|
const response: { ok: boolean; reason?: string } = {
|
||||||
ok: true,
|
ok: true,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
const sessId = cookies.get("session").value!;
|
||||||
|
const user = (await getSessionUser(sessId))!;
|
||||||
|
if (!user.is_admin && !user.is_moderator) {
|
||||||
|
throw new Error("Доступно только администраторам или модераторам");
|
||||||
|
}
|
||||||
|
|
||||||
const postId = parseInt(url.searchParams.get("id") ?? "");
|
const postId = parseInt(url.searchParams.get("id") ?? "");
|
||||||
if (!postId) {
|
if (!postId) {
|
||||||
throw new Error("Неправильный формат запроса");
|
throw new Error("Неправильный формат запроса");
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ const user = (await getSessionUser(sessId))!;
|
||||||
|
|
||||||
<Layout title="Пользователи">
|
<Layout title="Пользователи">
|
||||||
<main>
|
<main>
|
||||||
<Navbar is_user_admin={user.is_admin} />
|
<Navbar is_user_admin={user.is_admin} user={user} />
|
||||||
{user.is_admin && <StudyItemsList />}
|
{(user.is_admin || user.is_moderator) && <StudyItemsList />}
|
||||||
{user.is_admin && <TimetableEditor />}
|
{(user.is_admin || user.is_moderator) && <TimetableEditor />}
|
||||||
{!user.is_admin && <TimetableViewer user={user} />}
|
{!user.is_admin && !user.is_moderator && <TimetableViewer user={user} />}
|
||||||
</main>
|
</main>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ export async function post({ request, cookies }: APIContext) {
|
||||||
try {
|
try {
|
||||||
const sessId = cookies.get("session").value!;
|
const sessId = cookies.get("session").value!;
|
||||||
const user = (await getSessionUser(sessId))!;
|
const user = (await getSessionUser(sessId))!;
|
||||||
if (!user.is_admin) {
|
if (!user.is_admin && !user.is_moderator) {
|
||||||
throw new Error("Доступно только администраторам");
|
throw new Error("Доступно только администраторам или модераторам");
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ export async function post({ request, cookies }: APIContext) {
|
||||||
try {
|
try {
|
||||||
const sessId = cookies.get("session").value!;
|
const sessId = cookies.get("session").value!;
|
||||||
const user = (await getSessionUser(sessId))!;
|
const user = (await getSessionUser(sessId))!;
|
||||||
if (!user.is_admin) {
|
if (!user.is_admin && !user.is_moderator) {
|
||||||
throw new Error("Доступно только администраторам");
|
throw new Error("Доступно только администраторам или модераторам");
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ export async function post({ request, cookies }: APIContext) {
|
||||||
try {
|
try {
|
||||||
const sessId = cookies.get("session").value!;
|
const sessId = cookies.get("session").value!;
|
||||||
const user = (await getSessionUser(sessId))!;
|
const user = (await getSessionUser(sessId))!;
|
||||||
if (!user.is_admin) {
|
if (!user.is_admin && !user.is_moderator) {
|
||||||
throw new Error("Доступно только администраторам");
|
throw new Error("Доступно только администраторам или модераторам");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: UpdateTTModel = await request.json();
|
const data: UpdateTTModel = await request.json();
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ const isCurrentUser = user.id === openedUser.id;
|
||||||
|
|
||||||
<Layout title={`Пользователь ${openedUser.fullName ?? openedUser.login}`}>
|
<Layout title={`Пользователь ${openedUser.fullName ?? openedUser.login}`}>
|
||||||
<main data-lore={openedUser.lore} data-login={openedUser.login}>
|
<main data-lore={openedUser.lore} data-login={openedUser.login}>
|
||||||
<Navbar is_user_admin={user.is_admin} />
|
<Navbar is_user_admin={user.is_admin} user={user} />
|
||||||
<div class="container mt-4 d-flex flex-column gap-4">
|
<div class="container mt-4 d-flex flex-column gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="login" class="form-label">Логин</label>
|
<label for="login" class="form-label">Логин</label>
|
||||||
|
|
@ -42,13 +42,13 @@ const isCurrentUser = user.id === openedUser.id;
|
||||||
{
|
{
|
||||||
isCurrentUser ? (
|
isCurrentUser ? (
|
||||||
<div class="d-flex flex-row gap-4">
|
<div class="d-flex flex-row gap-4">
|
||||||
<input type="text" class="form-control" id="fullName" name="fullName" value={openedUser.fullName ?? "Не установлено"} />
|
<input type="text" class="form-control" id="fullName" name="fullName" value={openedUser.fullName ?? ""} placeholder="Не установлено" />
|
||||||
<button class="btn btn-warning" onclick={`changeName("${user.login}")`}>
|
<button class="btn btn-warning" onclick={`changeName("${user.login}")`}>
|
||||||
Изменить
|
Изменить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<input type="email" class="form-control" id="fullName" name="fullName" value={openedUser.fullName ?? "Не установлено"} readonly />
|
<input type="email" class="form-control" id="fullName" name="fullName" value={openedUser.fullName ?? ""} placeholder="Не установлено" readonly />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
42
src/pages/userapi/makeUserModerator.ts
Normal file
42
src/pages/userapi/makeUserModerator.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { APIContext } from "astro";
|
||||||
|
import { makeUserModerator, getSessionUser } 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))!;
|
||||||
|
if (!user.is_admin) {
|
||||||
|
throw new Error("Доступно только администраторам");
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const login = formData.get("login");
|
||||||
|
if (login === null) {
|
||||||
|
throw new Error("Не предоставлены данные для наделения полномочиями модератора");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await makeUserModerator(login.toString());
|
||||||
|
if (updatedUser === null) {
|
||||||
|
throw new Error("Не удалось наделить полномочиями модератора");
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,8 @@ export async function post({ request, cookies }: APIContext) {
|
||||||
try {
|
try {
|
||||||
const sessId = cookies.get("session").value!;
|
const sessId = cookies.get("session").value!;
|
||||||
const user = (await getSessionUser(sessId))!;
|
const user = (await getSessionUser(sessId))!;
|
||||||
if (!user.is_admin) {
|
if (!user.is_admin && !user.is_moderator) {
|
||||||
throw new Error("Доступно только администраторам");
|
throw new Error("Доступно только администраторам или модераторам");
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,16 @@ const user = (await getSessionUser(sessId))!;
|
||||||
|
|
||||||
const sLogin = Astro.url.searchParams.get("login");
|
const sLogin = Astro.url.searchParams.get("login");
|
||||||
const sFullName = Astro.url.searchParams.get("fullName");
|
const sFullName = Astro.url.searchParams.get("fullName");
|
||||||
const sIsAdmin = Astro.url.searchParams.get("isAdmin");
|
|
||||||
const users = await searchUsers({
|
const users = await searchUsers({
|
||||||
login: sLogin ? sLogin : undefined,
|
login: sLogin ? sLogin : undefined,
|
||||||
fullName: sFullName ? sFullName : undefined,
|
fullName: sFullName ? sFullName : undefined,
|
||||||
isAdmin: sIsAdmin ? sIsAdmin === "isAdmin" : undefined,
|
isAdmin: undefined,
|
||||||
});
|
});
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Пользователи">
|
<Layout title="Пользователи">
|
||||||
<main>
|
<main>
|
||||||
<Navbar is_user_admin={user.is_admin} />
|
<Navbar is_user_admin={user.is_admin} user={user} />
|
||||||
<div class="container mt-4 d-flex flex-column gap-4">
|
<div class="container mt-4 d-flex flex-column gap-4">
|
||||||
<form class="mb-4" method="GET" action="/users">
|
<form class="mb-4" method="GET" action="/users">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
|
|
@ -41,21 +40,11 @@ const users = await searchUsers({
|
||||||
<label for="fullName" class="form-label">Ф.И.О.</label>
|
<label for="fullName" class="form-label">Ф.И.О.</label>
|
||||||
<input type="text" class="form-control form-control-sm" name="fullName" id="fullName" value={Astro.url.searchParams.get("fullName")} />
|
<input type="text" class="form-control form-control-sm" name="fullName" id="fullName" value={Astro.url.searchParams.get("fullName")} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
|
||||||
{
|
|
||||||
sIsAdmin === "isAdmin" ? (
|
|
||||||
<input class="form-check-input" type="checkbox" value="isAdmin" name="isAdmin" id="isAdmin" checked />
|
|
||||||
) : (
|
|
||||||
<input class="form-check-input" type="checkbox" value="isAdmin" name="isAdmin" id="isAdmin" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<label class="form-check-label" for="isAdmin">Администратор</label>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-sm btn-warning w-100">Найти</button>
|
<button type="submit" class="btn btn-sm btn-warning w-100">Найти</button>
|
||||||
</form>
|
</form>
|
||||||
{
|
{
|
||||||
users.map((e) => (
|
users.map((e) => (
|
||||||
<div class="card flex-grow-1">
|
<div class="card flex-grow-1 border border-5">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">{e.fullName}</h5>
|
<h5 class="card-title">{e.fullName}</h5>
|
||||||
<h6 class="card-subtitle mb-2 text-muted">{e.login}</h6>
|
<h6 class="card-subtitle mb-2 text-muted">{e.login}</h6>
|
||||||
|
|
@ -63,17 +52,24 @@ const users = await searchUsers({
|
||||||
<a href={`/user/${e.login}`} class="btn btn-primary btn-sm">
|
<a href={`/user/${e.login}`} class="btn btn-primary btn-sm">
|
||||||
Открыть профиль
|
Открыть профиль
|
||||||
</a>
|
</a>
|
||||||
{user.is_admin ? (
|
{(user.is_admin || user.is_moderator) && !e.is_admin ? (
|
||||||
<button type="button" class="btn btn-primary btn-sm" onclick={`doChangePassword("${e.login}")`}>
|
<button type="button" class="btn btn-primary btn-sm" onclick={`doChangePassword("${e.login}")`}>
|
||||||
Изменить пароль
|
Изменить пароль
|
||||||
</button>
|
</button>
|
||||||
<a href={`/timetable?userId=${e.id}`} class="btn btn-primary btn-sm">
|
<a href={`/timetable?login=${e.login}`} class="btn btn-primary btn-sm">
|
||||||
Редактировать расписание
|
Редактировать расписание
|
||||||
</a>
|
</a>
|
||||||
|
) : null}
|
||||||
|
{user.is_admin && e.id !== user.id && !e.is_admin ? (
|
||||||
<button type="button" class="btn btn-danger btn-sm" onclick={`doDeleteUser("${e.login}")`}>
|
<button type="button" class="btn btn-danger btn-sm" onclick={`doDeleteUser("${e.login}")`}>
|
||||||
Удалить пользователя
|
Удалить пользователя
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{user.is_admin && !e.is_admin && !e.is_moderator ? (
|
||||||
|
<button type="button" class="btn btn-warning btn-sm" onclick={`doMakeModerator("${e.login}")`}>
|
||||||
|
Назначить модератором
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,6 +107,29 @@ const users = await searchUsers({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function doMakeModerator(login) {
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("login", login);
|
||||||
|
const resp = await fetch("/userapi/makeUserModerator", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const json = await resp.json();
|
||||||
|
if (json.ok) {
|
||||||
|
alert("Успех");
|
||||||
|
} else {
|
||||||
|
throw new Error(json.reason);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (e instanceof Error) {
|
||||||
|
alert(e.message);
|
||||||
|
} else {
|
||||||
|
alert("Неизвестная ошибка");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function doDeleteUser(login) {
|
async function doDeleteUser(login) {
|
||||||
const confirmLogin = prompt(`Это действие невозможно отменить. \nВведите логин пользователя '${login}' для подтверждения`);
|
const confirmLogin = prompt(`Это действие невозможно отменить. \nВведите логин пользователя '${login}' для подтверждения`);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue