Domovská stránka

This commit is contained in:
sneedmaster
2026-04-15 16:27:06 +02:00
parent 88f248dcae
commit 898afa690e
20 changed files with 297 additions and 77 deletions

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -133,4 +133,5 @@ const timestringToSecs = (time: string) => {
}
};
export {};
export { dateToReltime, secsToDuration, secsToTimestring, timestringToSecs };

View File

@@ -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"

View 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">
{"&nbsp;"}od <span safe>{news.created_by}</span> -{" "}
<Datetime ctx={ctx} date={news.created} />
</small>
</>
);
export { NewsPost };

View File

@@ -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 };
};

View File

@@ -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>

View File

@@ -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":

View File

@@ -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":

View File

@@ -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":

View File

@@ -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":

View File

@@ -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":

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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":

View File

@@ -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;

View File

@@ -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">
{"&nbsp;"}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>
);

View File

@@ -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) {

View File

@@ -164,7 +164,6 @@ select,
.form-table,
.pagination,
.news-post,
.inner-footer,
.reply,
.catalog-tile,