Domovská stránka
This commit is contained in:
@@ -61,6 +61,13 @@ const readBoard = async (id: string): Promise<Board> => {
|
||||
return board;
|
||||
};
|
||||
|
||||
// Reads listed boards
|
||||
const readListedBoards = async () => {
|
||||
const ids = await valkey.lrange("idx:boards:listed", 0, -1);
|
||||
const boards = await readBoards(ids);
|
||||
return boards;
|
||||
};
|
||||
|
||||
// Gets the next post's ID
|
||||
// No cache here
|
||||
const readNextID = async (board: string): Promise<number> => {
|
||||
@@ -70,10 +77,19 @@ const readNextID = async (board: string): Promise<number> => {
|
||||
);
|
||||
|
||||
const id = result.rows[0].id;
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
// Reads the total historic post count
|
||||
const readPostCount = async (board: string): Promise<number> => {
|
||||
const result = await postgres.query<{ count: string }>(
|
||||
`SELECT last_value AS count FROM posts_${board}`,
|
||||
);
|
||||
|
||||
const count = Number(result.rows[0].count);
|
||||
return count;
|
||||
};
|
||||
|
||||
// Updates the board's name
|
||||
const updateBoardName = async (board: Board, name: string) => {
|
||||
const result = await postgres.query<Board>(
|
||||
@@ -151,7 +167,9 @@ export {
|
||||
readBoards,
|
||||
readAllBoards,
|
||||
readBoard,
|
||||
readListedBoards,
|
||||
readNextID,
|
||||
readPostCount,
|
||||
updateBoardName,
|
||||
updateBoardDescription,
|
||||
updateBoardConfig,
|
||||
|
||||
@@ -50,6 +50,13 @@ const readNewsPost = async (id: number): Promise<News> => {
|
||||
return news;
|
||||
};
|
||||
|
||||
// Reads the latest news
|
||||
const readLatestNewsPost = async (): Promise<News | null> => {
|
||||
const [id] = await valkey.zrevrange("idx:news:created", 0, 0);
|
||||
if (id) return await readNewsPost(Number(id));
|
||||
else return null;
|
||||
};
|
||||
|
||||
// Updates news
|
||||
const updateNews = async (
|
||||
news: News,
|
||||
@@ -79,6 +86,7 @@ export {
|
||||
readNews,
|
||||
readAllNews,
|
||||
readNewsPost,
|
||||
readLatestNewsPost,
|
||||
updateNews,
|
||||
deleteNews,
|
||||
};
|
||||
|
||||
@@ -133,4 +133,5 @@ const timestringToSecs = (time: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export {};
|
||||
export { dateToReltime, secsToDuration, secsToTimestring, timestringToSecs };
|
||||
|
||||
@@ -22,8 +22,8 @@ const PageBody = ({
|
||||
{
|
||||
ctx.listedBoards
|
||||
.map((board) => (
|
||||
<a href={`/ib/${board}`} safe>
|
||||
{board}
|
||||
<a href={`/ib/${board.id}`} safe>
|
||||
{board.id}
|
||||
</a>
|
||||
))
|
||||
.join(" / ") as "safe"
|
||||
|
||||
45
src/web/components/content/news_post.tsx
Normal file
45
src/web/components/content/news_post.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { News } from "../../../schema/tables";
|
||||
import { TemplateCtx } from "../../ctx";
|
||||
import Datetime from "../primitives/datetime";
|
||||
import { PropsWithChildren } from "@kitajs/html";
|
||||
|
||||
const NewsPost = ({
|
||||
ctx,
|
||||
news,
|
||||
embedded = false,
|
||||
}: PropsWithChildren<{ ctx: TemplateCtx; news: News; embedded?: boolean }>) =>
|
||||
embedded ? (
|
||||
<>
|
||||
<h3 class="clearfix news-subject">
|
||||
<NewsHeader ctx={ctx} news={news} />
|
||||
</h3>
|
||||
<hr />
|
||||
<div class="post-content">{news.content as "safe"}</div>
|
||||
<hr />
|
||||
<div>
|
||||
<a href="/news">Všechny novinky →</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<article class="infobox">
|
||||
<h2 class="infobox-head clearfix">
|
||||
<NewsHeader ctx={ctx} news={news} />
|
||||
</h2>
|
||||
<div class="infobox-body post-content">{news.content as "safe"}</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
const NewsHeader = ({
|
||||
ctx,
|
||||
news,
|
||||
}: PropsWithChildren<{ ctx: TemplateCtx; news: News }>) => (
|
||||
<>
|
||||
<span safe>{news.subject}</span>
|
||||
<small class="news-details">
|
||||
{" "}od <span safe>{news.created_by}</span> -{" "}
|
||||
<Datetime ctx={ctx} date={news.created} />
|
||||
</small>
|
||||
</>
|
||||
);
|
||||
|
||||
export { NewsPost };
|
||||
@@ -1,6 +1,6 @@
|
||||
import { valkey } from "../cache";
|
||||
import { CzchanConfig } from "../config";
|
||||
import { User } from "../schema/tables";
|
||||
import { readListedBoards } from "../db/board";
|
||||
import { Board, User } from "../schema/tables";
|
||||
import { IPInfo, UConfig } from "./middleware";
|
||||
import { Request } from "express";
|
||||
|
||||
@@ -11,7 +11,7 @@ type TemplateCtx = {
|
||||
uconfig: UConfig;
|
||||
config: CzchanConfig;
|
||||
user: User | null;
|
||||
listedBoards: string[]; // Board links
|
||||
listedBoards: Board[]; // Board links
|
||||
};
|
||||
|
||||
const getCtx = async (req: Request): Promise<TemplateCtx> => {
|
||||
@@ -19,7 +19,7 @@ const getCtx = async (req: Request): Promise<TemplateCtx> => {
|
||||
const config = req.config;
|
||||
const uconfig = req.uconfig;
|
||||
const user = req.user;
|
||||
const listedBoards = await valkey.lrange("idx:boards:listed", 0, -1);
|
||||
const listedBoards = await readListedBoards();
|
||||
|
||||
return { ipInfo, uconfig, config, user, listedBoards };
|
||||
};
|
||||
|
||||
@@ -1,17 +1,119 @@
|
||||
import { valkey } from "../../cache";
|
||||
import { readAllBoards, readPostCount } from "../../db/board";
|
||||
import { readAllFileRecords } from "../../db/file_record";
|
||||
import { readLatestNewsPost } from "../../db/news";
|
||||
import { bytesToSize } from "../../lib/util";
|
||||
import { Board, News } from "../../schema/tables";
|
||||
import Page from "../components/chrome/page";
|
||||
import PageBody from "../components/chrome/page_body";
|
||||
import PageHead from "../components/chrome/page_head";
|
||||
import { NewsPost } from "../components/content/news_post";
|
||||
import Datetime from "../components/primitives/datetime";
|
||||
import { TemplateCtx, getCtx } from "../ctx";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
export default async (req: Request, res: Response) => {
|
||||
const ctx = await getCtx(req);
|
||||
const html = Template(ctx);
|
||||
|
||||
// News
|
||||
const news = await readLatestNewsPost();
|
||||
|
||||
// Get board stats
|
||||
const allBoards = await readAllBoards();
|
||||
|
||||
const now = Date.now();
|
||||
const hourAgo = now - 3600 * 1000;
|
||||
const dayAgo = now - 86400 * 1000;
|
||||
|
||||
let totalPosts = 0;
|
||||
let totalLastPost = null;
|
||||
const allBoardsStats = [];
|
||||
|
||||
for (const board of allBoards) {
|
||||
const posts = await readPostCount(board.id);
|
||||
|
||||
const pph = await valkey.zcount(
|
||||
`idx:posts:board:${board.id}:created`,
|
||||
hourAgo,
|
||||
now,
|
||||
);
|
||||
|
||||
const ppd = await valkey.zcount(
|
||||
`idx:posts:board:${board.id}:created`,
|
||||
dayAgo,
|
||||
now,
|
||||
);
|
||||
|
||||
const [_, timestamp] = await valkey.zrevrange(
|
||||
`idx:posts:board:${board.id}:created`,
|
||||
0,
|
||||
0,
|
||||
"WITHSCORES",
|
||||
);
|
||||
|
||||
const lastPost = timestamp ? new Date(Number(timestamp)) : null;
|
||||
allBoardsStats.push({ board, pph, ppd, posts, lastPost });
|
||||
|
||||
totalPosts += posts;
|
||||
if ((lastPost?.getTime() || 0) > (totalLastPost?.getTime() || 0))
|
||||
totalLastPost = lastPost;
|
||||
}
|
||||
|
||||
const boardsStats = allBoardsStats
|
||||
.filter(({ board }) => !board.config.unlisted && !board.config.private)
|
||||
.sort((a, b) => b.posts - a.posts);
|
||||
|
||||
// Get total stats
|
||||
const pph = await valkey.zcount("idx:posts:all:created", hourAgo, now);
|
||||
const ppd = await valkey.zcount("idx:posts:all:created", dayAgo, now);
|
||||
|
||||
const totalBoards = allBoards.length;
|
||||
const listedBoards = ctx.listedBoards.length;
|
||||
|
||||
const records = await readAllFileRecords();
|
||||
|
||||
const files = records.length;
|
||||
const bytes = records
|
||||
.map(({ file }) => file.size)
|
||||
.reduce((acc, cur) => acc + cur);
|
||||
|
||||
const totalStats = {
|
||||
pph,
|
||||
ppd,
|
||||
totalPosts,
|
||||
totalLastPost,
|
||||
totalBoards,
|
||||
listedBoards,
|
||||
files,
|
||||
bytes,
|
||||
};
|
||||
|
||||
const html = Template(ctx, news, boardsStats, totalStats);
|
||||
|
||||
res.send(html);
|
||||
};
|
||||
|
||||
const Template = (ctx: TemplateCtx) => (
|
||||
const Template = (
|
||||
ctx: TemplateCtx,
|
||||
news: News | null,
|
||||
boardsStats: {
|
||||
board: Board;
|
||||
posts: number;
|
||||
pph: number;
|
||||
ppd: number;
|
||||
lastPost: Date | null;
|
||||
}[],
|
||||
totalStats: {
|
||||
pph: number;
|
||||
ppd: number;
|
||||
totalPosts: number;
|
||||
totalLastPost: Date | null;
|
||||
totalBoards: number;
|
||||
listedBoards: number;
|
||||
files: number;
|
||||
bytes: number;
|
||||
},
|
||||
) => (
|
||||
<Page>
|
||||
<PageHead
|
||||
ctx={ctx}
|
||||
@@ -19,11 +121,97 @@ const Template = (ctx: TemplateCtx) => (
|
||||
description={ctx.config.site.ui.description}
|
||||
/>
|
||||
<PageBody ctx={ctx}>
|
||||
<div class="center">
|
||||
<div class="container">
|
||||
<img class="logo" src="/public/img/logo.png" />
|
||||
<p class="subtitle">Český anonymní imageboard</p>
|
||||
<hr />
|
||||
<hr />
|
||||
<p class="subtitle center">Český anonymní imageboard</p>
|
||||
<div class="infobox">
|
||||
<h2 class="infobox-head">Co je CZchan?</h2>
|
||||
<div class="infobox-body">
|
||||
<p>
|
||||
CZchan je české diskusní fórum v tradičním formátu (imageboard)
|
||||
zaměřené na svobodnou diskusi a sdílení obrázků. Fórum je
|
||||
rozděleno na nástěneky dle témata. Příspěvky jsou zde zcela
|
||||
anonymní, takže lze přispívat bez registrace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{news !== null && (
|
||||
<div class="infobox">
|
||||
<h2 class="infobox-head">Poslední novinky</h2>
|
||||
<div class="infobox-body">
|
||||
<NewsPost ctx={ctx} news={news} embedded />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div class="infobox">
|
||||
<h2 class="infobox-head">Nástěnky</h2>
|
||||
<div class="infobox-body">
|
||||
<table class="data-table full-width tiny-mb">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nástěnka</th>
|
||||
<th>Počet příspěvků</th>
|
||||
<th>PPH</th>
|
||||
<th>PPD</th>
|
||||
<th>Poslední příspěvek</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{boardsStats.map(({ board, posts, ppd, pph, lastPost }) => (
|
||||
<tr>
|
||||
<td>
|
||||
<a href={`/ib/${board.id}`}>
|
||||
<span safe>
|
||||
/{board.id}/ - {board.name}
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>{posts}</td>
|
||||
<td>{pph}</td>
|
||||
<td>{ppd}</td>
|
||||
<td>
|
||||
<Datetime ctx={ctx} date={lastPost} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<th>Celkem</th>
|
||||
<th>{totalStats.totalPosts}</th>
|
||||
<th>{totalStats.pph}</th>
|
||||
<th>{totalStats.ppd}</th>
|
||||
<th>
|
||||
<Datetime ctx={ctx} date={totalStats.totalLastPost} />
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="infobox">
|
||||
<h2 class="infobox-head">Statistika</h2>
|
||||
<div class="infobox-body">
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Celkem nástěnek</th>
|
||||
<td>{totalStats.totalBoards}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>↳ Z toho veřejných</th>
|
||||
<td>{totalStats.listedBoards}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Celkem souborů</th>
|
||||
<td>{totalStats.files}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>↳ O velikosti</th>
|
||||
<td safe>{bytesToSize(totalStats.bytes)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</Page>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
deleteBan,
|
||||
readBan,
|
||||
readBans,
|
||||
updateBanAppealable,
|
||||
updateBanAppealResponse,
|
||||
} from "../../../../db/ban";
|
||||
@@ -20,9 +20,7 @@ export default async (req: Request, res: Response) => {
|
||||
const { bans, action, appeal_response, unappealable } =
|
||||
BanActionsForm.parse(fields);
|
||||
|
||||
const bans_ = (
|
||||
await Promise.all(bans.map(async (id) => await readBan(id)))
|
||||
).filter((ban) => ban !== null);
|
||||
const bans_ = await readBans(bans);
|
||||
|
||||
switch (action) {
|
||||
case "delete_ban":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { deleteBanner, readBanner } from "../../../../db/banner";
|
||||
import { deleteBanner, readBanners } from "../../../../db/banner";
|
||||
import { CzchanError } from "../../../../lib/error";
|
||||
import { canUser, CzchanPerm } from "../../../../lib/permissions";
|
||||
import { Banner } from "../../../../schema/tables";
|
||||
@@ -14,9 +14,7 @@ export default async (req: Request, res: Response) => {
|
||||
const [fields, _] = await parser.parse(req);
|
||||
const { banners, action } = BannerActionsForm.parse(fields);
|
||||
|
||||
const banners_ = await Promise.all(
|
||||
banners.map(async (id) => await readBanner(id)),
|
||||
);
|
||||
const banners_ = await readBanners(banners);
|
||||
|
||||
switch (action) {
|
||||
case "delete_banner":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
deleteBoard,
|
||||
readBoard,
|
||||
readBoards,
|
||||
updateBoardDescription,
|
||||
updateBoardName,
|
||||
} from "../../../../db/board";
|
||||
@@ -22,9 +22,7 @@ export default async (req: Request, res: Response) => {
|
||||
const [fields, _] = await parser.parse(req);
|
||||
const { boards, action } = BoardActionsForm.parse(fields);
|
||||
|
||||
const boards_ = await Promise.all(
|
||||
boards.map(async (id) => await readBoard(id)),
|
||||
);
|
||||
const boards_ = await readBoards(boards);
|
||||
|
||||
switch (action) {
|
||||
case "delete_board":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
deleteFilter,
|
||||
readFilter,
|
||||
readFilters,
|
||||
updateFilterPriority,
|
||||
} from "../../../../db/filter";
|
||||
import { CzchanError } from "../../../../lib/error";
|
||||
@@ -21,9 +21,7 @@ export default async (req: Request, res: Response) => {
|
||||
const [fields, _] = await parser.parse(req);
|
||||
const { filters, action } = FilterActionsForm.parse(fields);
|
||||
|
||||
const filters_ = await Promise.all(
|
||||
filters.map(async (id) => await readFilter(id)),
|
||||
);
|
||||
const filters_ = await readFilters(filters);
|
||||
|
||||
switch (action) {
|
||||
case "delete_filter":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { deleteNews, readNewsPost } from "../../../../db/news";
|
||||
import { deleteNews, readNews } from "../../../../db/news";
|
||||
import { CzchanError } from "../../../../lib/error";
|
||||
import { canUser, CzchanPerm } from "../../../../lib/permissions";
|
||||
import { News } from "../../../../schema/tables";
|
||||
@@ -14,9 +14,7 @@ export default async (req: Request, res: Response) => {
|
||||
const [fields, _] = await parser.parse(req);
|
||||
const { news, action } = NewsActionsForm.parse(fields);
|
||||
|
||||
const news_ = await Promise.all(
|
||||
news.map(async (id) => await readNewsPost(id)),
|
||||
);
|
||||
const news_ = await readNews(news);
|
||||
|
||||
switch (action) {
|
||||
case "delete_news":
|
||||
|
||||
@@ -37,9 +37,6 @@ export default async (req: Request, res: Response) => {
|
||||
!boards[post.board].config.private,
|
||||
);
|
||||
|
||||
if (posts.length === 0)
|
||||
throw new CzchanError("Nebyl vybrán žádný příspěvek.", 400);
|
||||
|
||||
if (!form.delete_posts && !form.ban_users)
|
||||
throw new CzchanError("Nebyla zvolena žádná akce.", 400);
|
||||
|
||||
|
||||
@@ -34,9 +34,6 @@ export default async (req: Request, res: Response) => {
|
||||
!boards[post.board].config.private,
|
||||
);
|
||||
|
||||
if (posts.length === 0)
|
||||
throw new CzchanError("Nebyl vybrán žádný příspěvek.", 400);
|
||||
|
||||
for (const post of posts) {
|
||||
if (post.thread !== null)
|
||||
throw new CzchanError("Atributy lze spravovat pouze u vláken.", 400);
|
||||
|
||||
@@ -28,9 +28,6 @@ export default async (req: Request, res: Response) => {
|
||||
if (!form.spoiler_files && !form.delete_files && !form.filter_files)
|
||||
throw new CzchanError("Nebyla zvolena žádná akce.", 400);
|
||||
|
||||
if (form.files.length === 0)
|
||||
throw new CzchanError("Nebyl vybrán žádný soubor.", 400);
|
||||
|
||||
const fileMap = new Map<string, string[]>();
|
||||
|
||||
// Build map
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { deleteRestriction, readRestriction } from "../../../../db/restriction";
|
||||
import {
|
||||
deleteRestriction,
|
||||
readRestrictions,
|
||||
} from "../../../../db/restriction";
|
||||
import { CzchanError } from "../../../../lib/error";
|
||||
import { canUser, CzchanPerm } from "../../../../lib/permissions";
|
||||
import { Restriction } from "../../../../schema/tables";
|
||||
@@ -14,9 +17,7 @@ export default async (req: Request, res: Response) => {
|
||||
const [fields, _] = await parser.parse(req);
|
||||
const { restrictions, action } = RestrictionActionsForm.parse(fields);
|
||||
|
||||
const restrictions_ = await Promise.all(
|
||||
restrictions.map(async (id) => await readRestriction(id)),
|
||||
);
|
||||
const restrictions_ = await readRestrictions(restrictions);
|
||||
|
||||
switch (action) {
|
||||
case "delete_restriction":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
deleteUser,
|
||||
readUser,
|
||||
readUsers,
|
||||
updateUserCapcode,
|
||||
updateUserPermissions,
|
||||
updateUserRank,
|
||||
@@ -28,9 +28,7 @@ export default async (req: Request, res: Response) => {
|
||||
const [fields, _] = await parser.parse(req);
|
||||
const { users, action } = UserActionsForm.parse(fields);
|
||||
|
||||
const users_ = await Promise.all(
|
||||
users.map(async (username) => await readUser(username)),
|
||||
);
|
||||
const users_ = await readUsers(users);
|
||||
|
||||
let selfDeletion = false;
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ import { News } from "../../schema/tables";
|
||||
import Page from "../components/chrome/page";
|
||||
import PageBody from "../components/chrome/page_body";
|
||||
import PageHead from "../components/chrome/page_head";
|
||||
import Datetime from "../components/primitives/datetime";
|
||||
import { NewsPost } from "../components/content/news_post";
|
||||
import { TemplateCtx, getCtx } from "../ctx";
|
||||
import { PropsWithChildren } from "@kitajs/html";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
export default async (req: Request, res: Response) => {
|
||||
@@ -29,19 +28,3 @@ const Template = (ctx: TemplateCtx, news: News[]) => (
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
|
||||
const NewsPost = ({
|
||||
ctx,
|
||||
news,
|
||||
}: PropsWithChildren<{ ctx: TemplateCtx; news: News }>) => (
|
||||
<article class="infobox">
|
||||
<h2 class="infobox-head clearfix">
|
||||
<span safe>{news.subject}</span>
|
||||
<small class="news-details">
|
||||
{" "}od <span safe>{news.created_by}</span> -{" "}
|
||||
<Datetime ctx={ctx} date={news.created} />
|
||||
</small>
|
||||
</h2>
|
||||
<div class="infobox-body post-content">{news.content as "safe"}</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ footer {
|
||||
/* Images */
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: auto;
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
@@ -132,13 +133,7 @@ footer {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.news-post {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.news-subject,
|
||||
.news-content {
|
||||
.news-subject {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -348,10 +343,13 @@ nav a,
|
||||
}
|
||||
|
||||
.full-width {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tiny-mb {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* PhoneGODS */
|
||||
|
||||
@media only screen and (max-width: 450px) {
|
||||
|
||||
@@ -164,7 +164,6 @@ select,
|
||||
|
||||
.form-table,
|
||||
.pagination,
|
||||
.news-post,
|
||||
.inner-footer,
|
||||
.reply,
|
||||
.catalog-tile,
|
||||
|
||||
Reference in New Issue
Block a user