Overboard

This commit is contained in:
sneedmaster
2026-04-20 00:05:51 +02:00
parent 73b78d6c76
commit ac9ea8f308
10 changed files with 266 additions and 40 deletions

View File

@@ -15,9 +15,8 @@ const createUser = async (
permissions: CzchanPerm[],
noCahce: boolean = false,
) => {
if (username === "system") {
if (username === "system")
throw new CzchanError('Jméno "system" je rezervované.', 400);
}
const result = await postgres.query<User>(
"INSERT INTO users (username, password, rank, capcode, permissions) VALUES ($1, $2, $3, $4, $5) RETURNING *",

View File

@@ -31,7 +31,7 @@ const PageBody = ({
))
.join(" / ") as "safe"
}
]
] [<a href="/ib/overboard">overboard</a>]{" "}
</>
)}{" "}
[<a href="/news">novinky</a>]

View File

@@ -5,45 +5,52 @@ const IBHeader = ({
banner,
board,
catalog = false,
}: PropsWithChildren<{ banner: string; board: Board; catalog?: boolean }>) => (
}: PropsWithChildren<{ banner: string; board?: Board; catalog?: boolean }>) => (
<div class="center">
<img class="banner" src={banner} />
<h1 class="title">
{catalog ? (
<>
Katalog (
<a href={`/ib/${board.id}`} safe>
/{board.id}/
</a>
{board ? (
<a href={`/ib/${board.id}`} safe>
/{board.id}/
</a>
) : (
<>Overboard</>
)}
)
</>
) : board ? (
<>
<span safe>
/{board.id}/ - {board.name}
</span>
{board.config.unlisted && (
<>
{" "}
<img class="icon" src="/public/img/icons/unlisted.png" />
</>
)}
{board.config.private && (
<>
{" "}
<img class="icon" src="/public/img/icons/lock.png" />
</>
)}
</>
) : (
<span safe>
/{board.id}/ - {board.name}
</span>
)}
{board.config.unlisted && (
<>
{" "}
<img class="icon" src="/public/img/icons/unlisted.png" />
</>
)}
{board.config.private && (
<>
{" "}
<img class="icon" src="/public/img/icons/lock.png" />
</>
<>Overboard</>
)}
</h1>
<p class="subtitle" safe>
{board.description}
<p class="subtitle">
{board ? board.description : <>Čerstvé příspěvky ze všech nástěnek</>}
</p>
<p>
{catalog ? (
<a href={`/ib/${board.id}`}>Index</a>
<a href={`/ib/${board?.id || "overboard"}`}>Index</a>
) : (
<a href={`/ib/${board.id}/catalog`}>Katalog</a>
<a href={`/ib/${board?.id || "overboard"}/catalog`}>Katalog</a>
)}
</p>
</div>

View File

@@ -4,17 +4,8 @@ import { PropsWithChildren } from "@kitajs/html";
const IBLinks = ({ board }: PropsWithChildren<{ board?: Board }>) => (
<>
[<a href="#top">Nahoru</a>] [<a href="#bottom">Dolů</a>] [
<a href={board !== undefined ? `/ib/${board.id}` : `/ib/overboard`}>
Index
</a>
] [
<a
href={
board !== undefined
? `/ib/${board.id}/catalog`
: `/ib/overboard/catalog`
}
>
<a href={board ? `/ib/${board.id}` : `/ib/overboard`}>Index</a>] [
<a href={board ? `/ib/${board.id}/catalog` : `/ib/overboard/catalog`}>
Katalog
</a>
]

View File

@@ -0,0 +1,155 @@
import { valkey } from "../../../cache";
import { readRandomBanner } from "../../../db/banner";
import { readAllBoards } from "../../../db/board";
import {
readOverboardThreadsPage,
readPostReplies,
readQPosts,
} from "../../../db/post";
import { csPlural } from "../../../lib/locale";
import { threadURL } from "../../../lib/util";
import { Board, Post } from "../../../schema/tables";
import { zPage } from "../../../schema/validation/common";
import Page from "../../components/chrome/page";
import PageBody from "../../components/chrome/page_body";
import PageHead from "../../components/chrome/page_head";
import PostComponent from "../../components/content/post";
import { PostActionsForm } from "../../components/forms/post_actions_form";
import IBHeader from "../../components/navigation/ib_header";
import IBLinks from "../../components/navigation/ib_links";
import Pagination from "../../components/navigation/pagination";
import { getCtx, TemplateCtx } from "../../ctx";
import { Request, Response } from "express";
export default async (req: Request, res: Response) => {
const ctx = await getCtx(req);
const banner = await readRandomBanner();
const boards = Object.fromEntries(
(await readAllBoards()).map((board) => [board.id, board]),
);
const totalThreads = await valkey.zcount(
`idx:posts:threads:all:bumped`,
"-inf",
"+inf",
);
const page = zPage.parse(req.query.page || "1");
const overboardBoards = Object.values(boards)
.filter((board) => !(board.config.private || board.config.unlisted))
.map((board) => board.id);
const ops = await readOverboardThreadsPage(req.config, page, overboardBoards);
const threads = [];
const posts = [];
for (const op of ops) {
const replies = await readPostReplies(op);
threads.push({ op, replies });
posts.push(op, ...replies);
}
const qposts = await readQPosts(posts);
const html = Template(
ctx,
banner,
boards,
page,
threads,
qposts,
totalThreads,
);
res.send(html);
};
const Template = (
ctx: TemplateCtx,
banner: string,
boards: { [key: string]: Board },
page: number,
threads: { op: Post; replies: Post[] }[],
qposts: { [key: string]: Post },
totalThreads: number,
) => (
<Page>
<PageHead
ctx={ctx}
title={`Overboard - Strana ${page}`}
description="Čerstvé příspěvky ze všech nástěnek"
/>
<PageBody ctx={ctx}>
<IBHeader banner={banner} />
<hr />
<Pagination
ctx={ctx}
base={`/ib/overboard`}
entries={totalThreads}
current={page}
otherLinks={<IBLinks />}
/>
<hr />
<form class="ajax-form" method="post" action="/actions/post_actions">
{threads.map((thread) => (
<>
<b>
Vlákno z{" "}
<a href={`/ib/boards/${thread.op.board}`} safe>
/{thread.op.board}/
</a>
</b>
<div class="thread">
<PostComponent
ctx={ctx}
board={boards[thread.op.board]}
post={thread.op}
qposts={qposts}
/>
{thread.replies.length > ctx.config.site.ui.preview_replies && (
<p>
{csPlural(
["Byla vynechána", "Byly vynechány", "Bylo vynecháno"],
thread.replies.length - ctx.config.site.ui.preview_replies,
)}{" "}
{thread.replies.length - ctx.config.site.ui.preview_replies}{" "}
{csPlural(
["odpověď", "odpovědi", "odpovědí"],
thread.replies.length - ctx.config.site.ui.preview_replies,
)}{" "}
<a href={threadURL(thread.op)}>Kliknutím se otevře vlákno.</a>
</p>
)}
{thread.replies
.slice(-ctx.config.site.ui.preview_replies)
.map((reply) => (
<>
<PostComponent
ctx={ctx}
board={boards[reply.board]}
post={reply}
qposts={qposts}
reply
/>
<br />
</>
))}
<hr />
</div>
</>
))}
<Pagination
ctx={ctx}
base={`/ib/overboard`}
entries={totalThreads}
current={page}
otherLinks={<IBLinks />}
/>
<hr />
<PostActionsForm ctx={ctx} />
<hr />
</form>
</PageBody>
</Page>
);

View File

@@ -0,0 +1,62 @@
import { readRandomBanner } from "../../../db/banner";
import { readAllBoards } from "../../../db/board";
import { readOverboardThreads } from "../../../db/post";
import { Post } from "../../../schema/tables";
import Page from "../../components/chrome/page";
import PageBody from "../../components/chrome/page_body";
import PageHead from "../../components/chrome/page_head";
import CatalogTile from "../../components/content/catalog_tile";
import { PostActionsForm } from "../../components/forms/post_actions_form";
import IBHeader from "../../components/navigation/ib_header";
import IBLinks from "../../components/navigation/ib_links";
import { getCtx, TemplateCtx } from "../../ctx";
import { Request, Response } from "express";
export default async (req: Request, res: Response) => {
const ctx = await getCtx(req);
const banner = await readRandomBanner();
const boards = Object.fromEntries(
(await readAllBoards()).map((board) => [board.id, board]),
);
const overboardBoards = Object.values(boards)
.filter((board) => !(board.config.private || board.config.unlisted))
.map((board) => board.id);
const posts = await readOverboardThreads(overboardBoards);
const html = Template(ctx, banner, posts);
res.send(html);
};
const Template = (ctx: TemplateCtx, banner: string, posts: Post[]) => (
<Page>
<PageHead
ctx={ctx}
title={`Overboard - Katalog`}
description="Katalog overboardu"
/>
<PageBody ctx={ctx}>
<IBHeader banner={banner} catalog />
<hr />
<nav class="pagination">
<IBLinks />
</nav>
<hr />
<form class="ajax-form" method="post" action="/actions/post_actions">
<div class="center">
{posts.map((post) => (
<CatalogTile post={post} />
))}
</div>
<hr />
<nav class="pagination">
<IBLinks />
</nav>
<hr />
<PostActionsForm ctx={ctx} />
<hr />
</form>
</PageBody>
</Page>
);

View File

@@ -14,6 +14,9 @@ export default async (req: Request, res: Response) => {
const [fields, _] = await parser.parse(req);
const { id, name, description } = CreateBoardForm.parse(fields);
if (id === "overboard")
throw new CzchanError('ID "overboard" je rezervované.', 400);
try {
await createBoard(
req.config,

View File

@@ -7,6 +7,8 @@ import actionUpdateUconfig from "../routes/actions/update_uconfig";
import captcha from "../routes/captcha";
import board from "../routes/ib/board";
import catalog from "../routes/ib/catalog";
import overboard from "../routes/ib/overboard";
import overboardCatalog from "../routes/ib/overboard_catalog";
import thread from "../routes/ib/thread";
import index from "../routes/index";
import login from "../routes/login";
@@ -22,6 +24,8 @@ const registerPublicRoutes = (router: Router) => {
router.get("/news", e(news));
router.get("/captcha", e(captcha));
router.get("/ib/overboard", e(overboard));
router.get("/ib/overboard/catalog", e(overboardCatalog));
router.get("/ib/:board", e(board));
router.get("/ib/:board/catalog", e(catalog));
router.get("/ib/:board/:id", e(thread));

View File

@@ -180,6 +180,7 @@ select,
}
.post.highlighted,
.catalog-tile:target,
.post:target {
border-right: 1px solid $hl-box-border;
border-bottom: 1px solid $hl-box-border;

View File

@@ -36,10 +36,14 @@ const hoverQuote = (quotes: JQuery<HTMLElement>) => {
return;
}
const board = quote.closest(".post").attr("data-board") || null;
const board =
quote.closest(".post, .catalog-tile").attr("data-board") || null;
const id = parseQuote(board, quote.text());
const existingPost = $(`#${id}.post`); // Catalog tiles don't count
const url = quote.attr("href");
const existingPost = $(`#${id}`);
console.log(id, existingPost[0]);
if (existingPost.length > 0 && isInViewport(existingPost)) {
existingPost.toggleClass("highlighted", hovering);