2 Commits

Author SHA1 Message Date
dexx 9de4992da5 Add error handling and storage safety checks to admin
Show inline error banners when card save/delete fails instead of
crashing. Prevent writes to local filesystem on Vercel where it would
silently fail by validating BLOB_READ_WRITE_TOKEN presence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 21:48:49 +03:00
dexx 8e2f75aa77 Replace Payload with Elysia admin
Co-authored-by: Codex <codex@openai.com>
2026-06-05 21:12:48 +03:00
27 changed files with 2108 additions and 989 deletions
+5
View File
@@ -0,0 +1,5 @@
ADMIN_PASSWORD=
API_URL=http://localhost:3001
PUBLIC_API_URL=http://localhost:3001
PUBLIC_SITE_URL=http://localhost:4321
BLOB_READ_WRITE_TOKEN=
+10
View File
@@ -1,5 +1,6 @@
node_modules/ node_modules/
.next/ .next/
.astro/
out/ out/
dist/ dist/
build/ build/
@@ -14,6 +15,15 @@ coverage/
*.obj *.obj
*.pdb *.pdb
.env* .env*
media/
payload.db*
apps/**/node_modules/
apps/**/.next/
apps/**/.astro/
apps/**/dist/
apps/**/media/
apps/**/payload.db*
apps/api/.data/
.DS_Store .DS_Store
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
+31 -14
View File
@@ -1,37 +1,54 @@
# pozor # pozor
Dark-only Next.js shame board powered by images from a GitHub repository. Dark-only shame board with a static Astro front end and a tiny Elysia admin/API.
## What it does ## Apps
- Loads image files from a public GitHub repository through the GitHub Contents API. - `apps/site`: Astro public board. It reads published cards from the Elysia API.
- Shows the images as a dark evidence-board style gallery. - `apps/api`: Elysia admin/API. It owns cards, uploads, and the `/admin` panel.
- Lets the user enter a repository, folder, and branch from the page.
- Shows an honest empty state when no source is configured or no images are found.
## Local development ## Local Development
```bash ```bash
bun install bun install
bun run dev bun run dev
``` ```
Then open `http://localhost:3000`. Open:
## Optional source configuration - Site: `http://localhost:4321`
- Admin: `http://localhost:3001/admin`
- API docs: `http://localhost:3001/docs`
- OpenAPI JSON: `http://localhost:3001/openapi.json`
The board can also be preconfigured with environment variables: ## Environment
The API stores cards and uploads in `apps/api/.data` locally.
On Vercel, set `BLOB_READ_WRITE_TOKEN` to store cards and uploaded images in Vercel Blob.
Set these values locally and on Vercel:
```bash ```bash
NEXT_PUBLIC_GITHUB_REPO=owner/repository ADMIN_PASSWORD=
NEXT_PUBLIC_GITHUB_PHOTOS_PATH=path/to/images API_URL=http://localhost:3001
NEXT_PUBLIC_GITHUB_BRANCH=main PUBLIC_API_URL=http://localhost:3001
PUBLIC_SITE_URL=http://localhost:4321
BLOB_READ_WRITE_TOKEN=
``` ```
If these values are not set, the page starts empty and waits for a real GitHub source to be entered. For deployment, point the Astro app at the deployed API origin by setting `API_URL` and `PUBLIC_API_URL` to that origin.
## Images
Cards support two image sources:
- `Image URL`: direct public image URL.
- `Upload image`: stored locally in development or in Vercel Blob when `BLOB_READ_WRITE_TOKEN` is set.
## Build ## Build
```bash ```bash
bun run build bun run build
``` ```
The Astro app can be hosted on Vercel as a static/front-end project. The Elysia API can be hosted on Vercel as a separate backend project using the Bun runtime.
-116
View File
@@ -1,116 +0,0 @@
import { NextResponse } from "next/server";
import { boardConfig } from "../../../board.config";
type GithubContent = {
name: string;
path: string;
type: "file" | "dir";
download_url: string | null;
html_url: string;
};
type ShamePhoto = {
name: string;
path: string;
url: string;
htmlUrl: string;
};
const imageExtensionPattern = /\.(avif|gif|jpe?g|png|webp)$/i;
function parseRepo(value: string | null) {
const clean = value?.trim().replace(/^https:\/\/github\.com\//, "") ?? "";
const [owner, repo] = clean.split("/");
if (!owner || !repo) {
return null;
}
return {
owner,
repo: repo.replace(/\.git$/, "")
};
}
async function fetchDirectory(owner: string, repo: string, path: string, branch?: string) {
const encodedPath = path
.split("/")
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join("/");
const url = new URL(`https://api.github.com/repos/${owner}/${repo}/contents/${encodedPath}`);
if (branch) {
url.searchParams.set("ref", branch);
}
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28"
},
next: { revalidate: 60 }
});
if (!response.ok) {
return {
ok: false as const,
status: response.status,
message: response.status === 404 ? "Repository or folder was not found." : "GitHub did not return the photo list."
};
}
const content = (await response.json()) as GithubContent[] | GithubContent;
const items = Array.isArray(content) ? content : [content];
return {
ok: true as const,
items
};
}
export async function GET() {
const branch = boardConfig.branch || undefined;
const path = boardConfig.photosPath;
const parsedRepo = parseRepo(boardConfig.repo);
if (!parsedRepo) {
return NextResponse.json({
photos: [],
repo: null,
path,
message: "Set a GitHub repository to load the board."
});
}
const directory = await fetchDirectory(parsedRepo.owner, parsedRepo.repo, path, branch);
if (!directory.ok) {
return NextResponse.json(
{
photos: [],
repo: `${parsedRepo.owner}/${parsedRepo.repo}`,
path,
message: directory.message
},
{ status: directory.status }
);
}
const photos: ShamePhoto[] = directory.items
.filter((item) => item.type === "file" && item.download_url && imageExtensionPattern.test(item.name))
.map((item) => ({
name: item.name.replace(imageExtensionPattern, ""),
path: item.path,
url: item.download_url as string,
htmlUrl: item.html_url
}));
return NextResponse.json({
photos,
repo: `${parsedRepo.owner}/${parsedRepo.repo}`,
path,
branch: branch ?? null,
message: photos.length ? null : "No image files were found in that GitHub folder."
});
}
-252
View File
@@ -1,252 +0,0 @@
:root {
--paper: #080908;
--ink: #f1eadc;
--muted: #a69a87;
--panel: #121511;
--blood: #e13131;
--acid: #d7ff3f;
--steel: #c8d3c6;
--line: #f1eadc;
--shadow: rgba(0, 0, 0, 0.56);
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
background:
radial-gradient(circle at 18% 10%, rgba(225, 49, 49, 0.16), transparent 28rem),
linear-gradient(90deg, rgba(241, 234, 220, 0.055) 1px, transparent 1px) 0 0 / 34px 34px,
linear-gradient(rgba(241, 234, 220, 0.04) 1px, transparent 1px) 0 0 / 34px 34px,
var(--paper);
}
body {
min-height: 100vh;
margin: 0;
color: var(--ink);
font-family: Georgia, "Times New Roman", serif;
}
button,
input {
font: inherit;
}
a {
color: inherit;
}
.shell {
width: min(1440px, calc(100% - 32px));
margin: 0 auto;
padding: 26px 0 56px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 380px);
gap: 24px;
align-items: stretch;
min-height: 270px;
border: 2px solid var(--line);
background:
radial-gradient(circle at 88% 18%, rgba(215, 255, 63, 0.12), transparent 30%),
linear-gradient(135deg, #191d16 0%, #101310 58%, #050605 100%);
box-shadow: 10px 10px 0 var(--line);
position: relative;
overflow: hidden;
}
.hero::before {
content: "";
position: absolute;
inset: 14px;
border: 1px dashed rgba(241, 234, 220, 0.34);
pointer-events: none;
}
.heroText {
padding: 34px;
position: relative;
z-index: 1;
}
.kicker {
width: max-content;
margin: 0 0 18px;
padding: 7px 10px;
background: var(--acid);
border: 2px solid var(--line);
color: #11160d;
font-family: "Courier New", monospace;
font-size: 0.83rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
max-width: 920px;
margin: 0;
font-size: clamp(4rem, 12vw, 10.5rem);
line-height: 0.8;
letter-spacing: 0;
text-transform: uppercase;
}
.summary {
max-width: 680px;
margin: 28px 0 0;
color: var(--muted);
font-size: 1.12rem;
line-height: 1.52;
}
.caseTag {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100%;
padding: 28px;
background: #050605;
color: var(--ink);
font-family: "Courier New", monospace;
text-transform: uppercase;
position: relative;
z-index: 1;
}
.caseTag span {
color: var(--acid);
font-size: 0.9rem;
font-weight: 800;
}
.caseTag strong {
overflow-wrap: anywhere;
font-size: 1.45rem;
line-height: 1.1;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 26px;
align-items: start;
min-height: 240px;
padding: 34px 0 12px;
}
.notice {
grid-column: 1 / -1;
padding: 24px;
border: 2px solid var(--line);
background: var(--panel);
box-shadow: 6px 6px 0 var(--line);
color: var(--steel);
font-size: 1rem;
}
.notice.danger {
background: #2a0808;
color: #ffb7a8;
}
.photoCard {
border: 2px solid var(--line);
background: #0d100d;
box-shadow: 8px 9px 0 var(--line);
transition: transform 180ms ease, box-shadow 180ms ease;
}
.photoCard:hover {
box-shadow: 12px 13px 0 var(--blood);
transform: translate(-3px, -3px);
}
.photoCard a {
display: block;
overflow: visible;
border-bottom: 2px solid var(--line);
background: #1b2019;
}
.photoCard img {
display: block;
width: 100%;
height: auto;
filter: contrast(1.12) saturate(0.82) brightness(0.86);
}
.caption {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 12px;
font-family: "Courier New", monospace;
}
.caption span {
padding: 4px 6px;
background: var(--blood);
color: white;
font-size: 0.78rem;
font-weight: 900;
}
.caption strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.92rem;
}
@media (max-width: 840px) {
.shell {
width: min(100% - 20px, 720px);
padding-top: 14px;
}
.hero {
grid-template-columns: 1fr;
}
.heroText {
padding: 26px 22px;
}
h1 {
font-size: clamp(3.4rem, 18vw, 6rem);
}
.summary {
font-size: 1rem;
}
.caseTag {
min-height: 130px;
}
.board {
padding-top: 24px;
}
}
@media (max-width: 520px) {
.board {
grid-template-columns: 1fr;
}
.hero {
box-shadow: 6px 6px 0 var(--line);
}
.kicker {
width: auto;
}
}
-19
View File
@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Доска позора",
description: "A GitHub-powered public shame board."
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ru">
<body>{children}</body>
</html>
);
}
-104
View File
@@ -1,104 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
type ShamePhoto = {
name: string;
path: string;
url: string;
htmlUrl: string;
};
type PhotoResponse = {
photos: ShamePhoto[];
repo: string | null;
path: string;
branch?: string | null;
message: string | null;
};
export default function Home() {
const [data, setData] = useState<PhotoResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function loadPhotos() {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/github-photos", {
signal: controller.signal
});
const nextData = (await response.json()) as PhotoResponse;
setData(nextData);
if (!response.ok) {
setError(nextData.message || "Could not load photos from GitHub.");
}
} catch (nextError) {
if (nextError instanceof DOMException && nextError.name === "AbortError") {
return;
}
setError("Could not reach the photo source.");
} finally {
setIsLoading(false);
}
}
loadPhotos();
return () => controller.abort();
}, []);
const repoLabel = data?.repo ?? "GitHub source not set";
const boardStamp = useMemo(() => {
const count = data?.photos.length ?? 0;
if (count === 0) {
return "Empty";
}
return `${count} filed`;
}, [data?.photos.length]);
return (
<main className="shell">
<section className="hero" aria-labelledby="page-title">
<div className="heroText">
<h1 id="page-title">Доска позора</h1>
<p className="summary">
тут только самое позорное.
</p>
</div>
<div className="caseTag" aria-label="Board status">
<span>{boardStamp}</span>
<strong>{repoLabel}</strong>
</div>
</section>
<section className="board" aria-live="polite" aria-busy={isLoading}>
{isLoading ? <div className="notice">Загружаю фотографии с GitHub...</div> : null}
{!isLoading && error ? <div className="notice danger">{error}</div> : null}
{!isLoading && !error && data?.message ? <div className="notice">{data.message}</div> : null}
{data?.photos.map((photo, index) => (
<article className="photoCard" key={photo.path}>
<a href={photo.htmlUrl} target="_blank" rel="noreferrer" aria-label={`Open ${photo.name} on GitHub`}>
<img src={photo.url} alt={photo.name} loading={index < 3 ? "eager" : "lazy"} />
</a>
<div className="caption">
<span>#{String(index + 1).padStart(2, "0")}</span>
<strong>{photo.name}</strong>
</div>
</article>
))}
</section>
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@pozor/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --env-file=../../.env --watch src/index.ts",
"build": "bun build src/index.ts --target=bun --outdir=dist",
"start": "bun --env-file=../../.env src/index.ts"
},
"dependencies": {
"@elysiajs/swagger": "1.3.0",
"@vercel/blob": "^2.4.0",
"elysia": "^1.4.17"
},
"devDependencies": {
"@types/bun": "^1.3.5",
"typescript": "5.8.3"
}
}
+483
View File
@@ -0,0 +1,483 @@
import type { BoardCard } from "./store";
const escapeHtml = (value: unknown) =>
String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
export function renderLogin(message = "") {
return pageShell(
"Login",
`
<main class="loginShell">
<section class="loginPanel">
<p class="stamp">Private board</p>
<h1>Admin</h1>
${message ? `<p class="notice">${escapeHtml(message)}</p>` : ""}
<form method="post" action="/admin/login" class="loginForm">
<label>
<span>Password</span>
<input name="password" type="password" autocomplete="current-password" autofocus />
</label>
<button type="submit">Login</button>
</form>
</section>
</main>
`
);
}
export function renderAdmin(cards: BoardCard[], options: { storageMode: string; message?: string }) {
const sorted = [...cards].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
return pageShell(
"Admin",
`
<main class="adminShell">
<header class="mast">
<div>
<p class="stamp">Elysia admin</p>
<h1>Доска<br />позора</h1>
</div>
<aside class="statusRail">
<span>${sorted.length} filed</span>
<strong>${escapeHtml(options.storageMode)}</strong>
<a class="smallButton linkButton" href="${escapeHtml(process.env.PUBLIC_SITE_URL || "http://localhost:4321")}">Home</a>
<a class="smallButton linkButton" href="/docs">API Docs</a>
<form method="post" action="/admin/logout">
<button type="submit" class="smallButton">Logout</button>
</form>
</aside>
</header>
${
options.message
? `<section class="noticeBanner">${escapeHtml(options.message)}</section>`
: ""
}
<section class="workbench">
<form class="editor" method="post" action="/admin/cards" enctype="multipart/form-data">
<p class="stamp">New card</p>
<label>
<span>Title</span>
<input name="title" required />
</label>
<label>
<span>Comment</span>
<textarea name="comment" rows="4"></textarea>
</label>
<label>
<span>Image URL</span>
<input name="imageUrl" inputmode="url" />
</label>
<label>
<span>Upload image</span>
<input name="image" type="file" accept="image/*" />
</label>
<label class="checkline">
<input name="published" type="checkbox" checked />
<span>Published</span>
</label>
<button type="submit">Create</button>
</form>
<section class="cards" aria-label="Cards">
${
sorted.length === 0
? `<div class="empty">No cards yet.</div>`
: sorted.map(renderCardEditor).join("")
}
</section>
</section>
</main>
`
);
}
function renderCardEditor(card: BoardCard) {
return `
<article class="cardEditor">
${card.imageUrl ? `<img src="${escapeHtml(card.imageUrl)}" alt="${escapeHtml(card.title)}" />` : `<div class="imageEmpty">No image</div>`}
<form method="post" action="/admin/cards" enctype="multipart/form-data">
<input type="hidden" name="id" value="${escapeHtml(card.id)}" />
<label>
<span>Title</span>
<input name="title" required value="${escapeHtml(card.title)}" />
</label>
<label>
<span>Comment</span>
<textarea name="comment" rows="3">${escapeHtml(card.comment)}</textarea>
</label>
<label>
<span>Image URL</span>
<input name="imageUrl" value="${escapeHtml(card.imageUrl)}" />
</label>
<label>
<span>Replace image</span>
<input name="image" type="file" accept="image/*" />
</label>
<label class="checkline">
<input name="published" type="checkbox" ${card.published ? "checked" : ""} />
<span>Published</span>
</label>
<div class="rowActions">
<button type="submit">Save</button>
<button type="submit" formaction="/admin/cards/delete" class="danger">Delete</button>
</div>
</form>
</article>
`;
}
function pageShell(title: string, body: string) {
return `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>${escapeHtml(title)} · Pozor Admin</title>
<style>${adminCss}</style>
</head>
<body>${body}</body>
</html>`;
}
const adminCss = `
/* Hallmark · pre-emit critique: P5 H4 E4 S5 R5 V4 */
:root {
--color-black: #000000;
--color-ink: #f7f7f7;
--color-panel: #111111;
--color-panel-raised: #181818;
--color-muted: #a7a7a7;
--color-line: #f7f7f7;
--color-line-soft: #565656;
--color-danger: #ffffff;
--shadow-hard: 8px 8px 0 var(--color-line);
--font-display: Georgia, "Times New Roman", serif;
--font-body: Georgia, "Times New Roman", serif;
--font-mono: "Courier New", monospace;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
overflow-x: clip;
background: var(--color-black);
color: var(--color-ink);
color-scheme: dark;
font-family: var(--font-body);
}
button,
input,
textarea {
font: inherit;
}
button,
input,
textarea {
border-radius: 0;
}
.adminShell,
.loginShell {
width: min(1480px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 64px;
}
.loginShell {
display: grid;
min-height: 100vh;
place-items: center;
}
.loginPanel,
.mast,
.editor,
.cardEditor,
.empty,
.noticeBanner {
border: 2px solid var(--color-line);
background: var(--color-panel);
box-shadow: var(--shadow-hard);
}
.loginPanel {
width: min(560px, 100%);
padding: 28px;
}
.loginPanel h1 {
font-size: clamp(3.6rem, 11vw, 7rem);
line-height: 0.9;
}
.mast {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
min-height: 290px;
}
.mast > div {
padding: 28px;
}
.stamp {
display: inline-block;
margin: 0;
padding: 7px 10px;
background: var(--color-ink);
color: var(--color-black);
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 900;
text-transform: uppercase;
}
h1 {
margin: 28px 0 0;
font-family: var(--font-display);
font-size: clamp(4rem, 10vw, 9rem);
line-height: 0.78;
overflow-wrap: anywhere;
text-transform: uppercase;
}
.statusRail {
display: grid;
grid-template-rows: auto 1fr auto auto auto;
gap: 18px;
padding: 28px;
border-left: 2px solid var(--color-line);
background: var(--color-black);
font-family: var(--font-mono);
text-transform: uppercase;
}
.statusRail span {
width: max-content;
max-width: 100%;
padding: 5px 7px;
background: var(--color-ink);
color: var(--color-black);
font-weight: 900;
}
.statusRail strong {
align-self: center;
overflow-wrap: anywhere;
}
.workbench {
display: grid;
grid-template-columns: minmax(280px, 380px) minmax(0, 1fr);
gap: 28px;
align-items: start;
padding-top: 36px;
}
.editor,
.cardEditor,
.empty,
.noticeBanner {
padding: 18px;
}
.noticeBanner {
margin-top: 28px;
color: var(--color-ink);
font-family: var(--font-mono);
overflow-wrap: anywhere;
}
.editor {
position: sticky;
top: 18px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
gap: 28px;
}
label {
display: grid;
gap: 8px;
margin-top: 16px;
}
label span {
color: var(--color-muted);
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 900;
text-transform: uppercase;
}
input,
textarea {
width: 100%;
min-width: 0;
border: 2px solid var(--color-line);
background: var(--color-black);
color: var(--color-ink);
padding: 10px 12px;
}
textarea {
resize: vertical;
}
input:focus-visible,
textarea:focus-visible,
button:focus-visible {
outline: 2px solid var(--color-line);
outline-offset: 3px;
}
.checkline {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
}
.checkline input {
width: 18px;
height: 18px;
}
button {
width: 100%;
min-height: 44px;
margin-top: 16px;
border: 2px solid var(--color-line);
background: var(--color-ink);
color: var(--color-black);
cursor: pointer;
font-family: var(--font-mono);
font-size: 0.86rem;
font-weight: 900;
text-transform: uppercase;
transition: background 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.linkButton {
display: grid;
place-items: center;
width: 100%;
min-height: 44px;
border: 2px solid var(--color-line);
background: var(--color-ink);
color: var(--color-black);
font-family: var(--font-mono);
font-size: 0.86rem;
font-weight: 900;
text-decoration: none;
text-transform: uppercase;
transition: background 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
button:hover {
background: var(--color-black);
color: var(--color-ink);
box-shadow: 4px 4px 0 var(--color-line);
}
.linkButton:hover,
.linkButton:focus-visible {
background: var(--color-black);
color: var(--color-ink);
outline: 2px solid var(--color-line);
outline-offset: 3px;
box-shadow: 4px 4px 0 var(--color-line);
}
.smallButton {
margin-top: 0;
}
.danger {
background: var(--color-black);
color: var(--color-ink);
}
.rowActions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
}
.cardEditor img,
.imageEmpty {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
border: 2px solid var(--color-line);
background: var(--color-black);
object-fit: cover;
}
.imageEmpty,
.empty,
.notice {
display: grid;
place-items: center;
min-height: 180px;
color: var(--color-muted);
}
.notice {
min-height: auto;
place-items: start;
margin: 24px 0 0;
}
.loginForm {
margin-top: 28px;
}
@media (max-width: 860px) {
.adminShell,
.loginShell {
width: min(100% - 20px, 720px);
padding-top: 14px;
}
.mast,
.workbench {
grid-template-columns: minmax(0, 1fr);
}
.statusRail {
border-top: 2px solid var(--color-line);
border-left: 0;
}
.editor {
position: static;
}
}
@media (max-width: 420px) {
.adminShell,
.loginShell {
width: min(100% - 14px, 390px);
}
.rowActions {
grid-template-columns: minmax(0, 1fr);
}
}
`;
+308
View File
@@ -0,0 +1,308 @@
import { swagger } from "@elysiajs/swagger";
import { Elysia } from "elysia";
import { readFile } from "node:fs/promises";
import { renderAdmin, renderLogin } from "./admin";
import {
type BoardCard,
deleteRemoteImage,
localUploadPath,
publicCards,
readCards,
saveImage,
storageMode,
writeCards
} from "./store";
const adminCookie = "pozor_admin";
const port = Number(process.env.PORT || 3001);
const html = (body: string, init?: ResponseInit) =>
new Response(body, {
...init,
headers: {
"Content-Type": "text/html; charset=utf-8",
...(init?.headers || {})
}
});
const redirect = (to: string, init?: ResponseInit) =>
new Response(null, {
status: 303,
...init,
headers: {
Location: to,
...(init?.headers || {})
}
});
const json = (body: unknown, init?: ResponseInit) =>
Response.json(body, {
...init,
headers: {
"Access-Control-Allow-Origin": "*",
...(init?.headers || {})
}
});
function parseCookies(request: Request) {
return Object.fromEntries(
(request.headers.get("cookie") || "")
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const index = part.indexOf("=");
return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))];
})
);
}
async function authToken() {
const password = getAdminPassword();
if (!password) {
return "";
}
const data = new TextEncoder().encode(password);
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function isAuthed(request: Request) {
const token = await authToken();
return Boolean(token) && parseCookies(request)[adminCookie] === token;
}
function getAdminPassword() {
return process.env.ADMIN_PASSWORD?.trim() || "";
}
async function cardFromForm(form: FormData, existing?: BoardCard): Promise<BoardCard> {
const now = new Date().toISOString();
const title = String(form.get("title") || "").trim();
const comment = String(form.get("comment") || "").trim();
let imageUrl = String(form.get("imageUrl") || "").trim();
const file = form.get("image");
if (file instanceof File && file.size > 0) {
if (existing?.imageUrl) {
await deleteRemoteImage(existing.imageUrl);
}
imageUrl = await saveImage(file);
}
return {
id: existing?.id || crypto.randomUUID(),
title,
comment,
imageUrl,
published: form.get("published") === "on",
createdAt: existing?.createdAt || now,
updatedAt: now
};
}
async function renderAdminWithMessage(message: string, status = 500) {
return html(renderAdmin(await readCards(), { storageMode, message }), { status });
}
const app = new Elysia()
.use(
swagger({
path: "/docs",
documentation: {
info: {
title: "Pozor Board API",
version: "1.0.0",
description:
"API and admin endpoints for the dark-only shame board. Public routes expose published cards; admin routes require the admin session cookie."
},
servers: [
{
url: process.env.PUBLIC_API_URL || `http://localhost:${port}`,
description: "API server"
}
],
tags: [
{
name: "public",
description: "Published board data"
},
{
name: "admin",
description: "Admin panel and card management"
},
{
name: "uploads",
description: "Local development uploads"
}
]
}
})
)
.get("/openapi.json", async ({ request }) => {
const url = new URL("/docs/json", request.url);
return fetch(url);
}, {
detail: {
tags: ["public"],
summary: "OpenAPI JSON alias",
description: "Redirects to the generated OpenAPI document."
}
})
.get("/", () => redirect("/admin"), {
detail: {
tags: ["admin"],
summary: "Redirect to admin panel"
}
})
.get("/api/cards", async () => {
const cards = publicCards(await readCards());
return json({ docs: cards });
}, {
detail: {
tags: ["public"],
summary: "List published cards",
description: "Returns cards visible on the public Astro board."
}
})
.get("/api/admin/cards", async ({ request }) => {
if (!(await isAuthed(request))) {
return json({ error: "Unauthorized" }, { status: 401 });
}
return json({ docs: await readCards() });
}, {
detail: {
tags: ["admin"],
summary: "List all cards",
description: "Requires a valid admin session cookie."
}
})
.get("/uploads/:filename", async ({ params }) => {
try {
const file = await readFile(localUploadPath(params.filename));
return new Response(file);
} catch {
return new Response("Not found", { status: 404 });
}
}, {
detail: {
tags: ["uploads"],
summary: "Read local uploaded file",
description: "Only used in local file storage mode. Blob uploads return public Blob URLs."
}
})
.get("/admin", async ({ request }) => {
if (!(await isAuthed(request))) {
return html(renderLogin(getAdminPassword() ? "" : "Set ADMIN_PASSWORD in .env to enable login."));
}
return html(renderAdmin(await readCards(), { storageMode }));
}, {
detail: {
tags: ["admin"],
summary: "Admin panel"
}
})
.post("/admin/login", async ({ request }) => {
const form = await request.formData();
const password = String(form.get("password") || "");
const adminPassword = getAdminPassword();
if (!adminPassword) {
return html(renderLogin("ADMIN_PASSWORD is not set."), { status: 403 });
}
if (adminPassword && password !== adminPassword) {
return html(renderLogin("Wrong password."), { status: 401 });
}
return redirect("/admin", {
headers: {
"Set-Cookie": `${adminCookie}=${await authToken()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`
}
});
}, {
detail: {
tags: ["admin"],
summary: "Login to admin panel",
description: "Accepts form data with a password field."
}
})
.post("/admin/logout", () =>
redirect("/admin", {
headers: {
"Set-Cookie": `${adminCookie}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
}
}), {
detail: {
tags: ["admin"],
summary: "Logout from admin panel"
}
}
)
.post("/admin/cards", async ({ request }) => {
if (!(await isAuthed(request))) {
return redirect("/admin");
}
try {
const form = await request.formData();
const cards = await readCards();
const id = String(form.get("id") || "");
const existing = cards.find((card) => card.id === id);
const nextCard = await cardFromForm(form, existing);
const nextCards = existing
? cards.map((card) => (card.id === existing.id ? nextCard : card))
: [nextCard, ...cards];
await writeCards(nextCards);
return redirect("/admin");
} catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card.");
}
}, {
detail: {
tags: ["admin"],
summary: "Create or update a card",
description: "Requires a valid admin session cookie. Accepts multipart form data."
}
})
.post("/admin/cards/delete", async ({ request }) => {
if (!(await isAuthed(request))) {
return redirect("/admin");
}
try {
const form = await request.formData();
const id = String(form.get("id") || "");
const cards = await readCards();
const existing = cards.find((card) => card.id === id);
if (existing?.imageUrl) {
await deleteRemoteImage(existing.imageUrl);
}
await writeCards(cards.filter((card) => card.id !== id));
return redirect("/admin");
} catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card.");
}
}, {
detail: {
tags: ["admin"],
summary: "Delete a card",
description: "Requires a valid admin session cookie. Accepts form data with an id field."
}
});
export default app;
if (process.env.VERCEL !== "1") {
app.listen(port);
console.log(`Pozor admin: http://localhost:${port}/admin`);
}
+136
View File
@@ -0,0 +1,136 @@
import { del, get, put } from "@vercel/blob";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
export type BoardCard = {
id: string;
title: string;
comment: string;
imageUrl: string;
published: boolean;
createdAt: string;
updatedAt: string;
};
const dataBlobPath = "pozor/cards.json";
const localDataDir = path.resolve(".data");
const localUploadsDir = path.join(localDataDir, "uploads");
const localCardsPath = path.join(localDataDir, "cards.json");
const hasBlobToken = () => Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim());
const isVercel = () => process.env.VERCEL === "1";
export const storageMode = hasBlobToken() ? "Vercel Blob" : "Local file";
export async function readCards(): Promise<BoardCard[]> {
if (hasBlobToken()) {
try {
const result = await get(dataBlobPath, { access: "private" });
if (!result?.stream) {
return [];
}
const text = await streamToText(result.stream);
const parsed = JSON.parse(text) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
try {
const text = await readFile(localCardsPath, "utf8");
const parsed = JSON.parse(text) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export async function writeCards(cards: BoardCard[]) {
const content = JSON.stringify(cards, null, 2);
if (hasBlobToken()) {
await put(dataBlobPath, content, {
access: "private",
contentType: "application/json",
allowOverwrite: true
});
return;
}
assertWritableLocalStorage();
await mkdir(localDataDir, { recursive: true });
await writeFile(localCardsPath, content, "utf8");
}
export async function saveImage(file: File) {
const safeName = safeFileName(file.name || "image");
const pathname = `pozor/images/${Date.now()}-${safeName}`;
if (hasBlobToken()) {
const blob = await put(pathname, file, {
access: "public",
contentType: file.type || "application/octet-stream"
});
return blob.url;
}
assertWritableLocalStorage();
await mkdir(localUploadsDir, { recursive: true });
const localName = `${Date.now()}-${safeName}`;
const localPath = path.join(localUploadsDir, localName);
await writeFile(localPath, Buffer.from(await file.arrayBuffer()));
return `/uploads/${localName}`;
}
export async function deleteRemoteImage(imageUrl: string) {
if (!hasBlobToken() || !imageUrl.includes(".blob.vercel-storage.com/")) {
return;
}
try {
await del(imageUrl);
} catch {
// Image deletion should not block card edits.
}
}
export function localUploadPath(filename: string) {
return path.join(localUploadsDir, safeFileName(filename));
}
function assertWritableLocalStorage() {
if (!isVercel()) {
return;
}
throw new Error(
"BLOB_READ_WRITE_TOKEN is not set for the API deployment. Vercel functions cannot write to .data; connect Vercel Blob to the API project and redeploy."
);
}
export function publicCards(cards: BoardCard[]) {
return cards
.filter((card) => card.published)
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
}
function safeFileName(name: string) {
return name.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-");
}
async function streamToText(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
}
return Buffer.concat(chunks).toString("utf8");
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src/**/*.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"bunVersion": "1.x"
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "astro/config";
export default defineConfig({
output: "static"
});
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@pozor/site",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "cross-env ASTRO_TELEMETRY_DISABLED=1 node ./node_modules/astro/astro.js dev --host 0.0.0.0 --port 4321",
"build": "cross-env ASTRO_TELEMETRY_DISABLED=1 node ./node_modules/astro/astro.js build",
"preview": "cross-env ASTRO_TELEMETRY_DISABLED=1 node ./node_modules/astro/astro.js preview --host 0.0.0.0 --port 4321"
},
"dependencies": {
"astro": "^5.8.2"
},
"devDependencies": {
"cross-env": "^10.1.0",
"typescript": "5.8.3"
}
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
+120
View File
@@ -0,0 +1,120 @@
---
import "../styles/global.css";
type BoardCard = {
id: string | number;
title: string;
comment?: string | null;
imageUrl?: string | null;
published?: boolean;
};
type ApiResponse = {
docs?: BoardCard[];
};
type BoardItem = {
id: string | number;
title: string;
comment?: string | null;
image: {
alt: string;
url: string;
} | null;
};
const apiUrl = (
import.meta.env.API_URL ||
import.meta.env.PUBLIC_API_URL ||
"http://localhost:3001"
).replace(/\/$/, "");
async function getCards() {
try {
const response = await fetch(`${apiUrl}/api/cards`);
if (!response.ok) {
return [];
}
const data = (await response.json()) as ApiResponse;
return data.docs ?? [];
} catch {
return [];
}
}
function absolutize(url: string) {
if (/^https?:\/\//i.test(url)) {
return url;
}
return `${apiUrl}${url.startsWith("/") ? "" : "/"}${url}`;
}
function getImage(card: BoardCard) {
if (card.imageUrl) {
return {
alt: card.title,
url: absolutize(card.imageUrl)
};
}
return null;
}
const cards = await getCards();
const boardItems: BoardItem[] = cards.map((card) => ({
id: card.id,
title: card.title,
comment: card.comment,
image: getImage(card)
}));
const boardStamp = boardItems.length === 0 ? "Empty" : `${boardItems.length} filed`;
const adminUrl = `${apiUrl}/admin`;
---
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content="#000000" />
<title>Доска позора</title>
</head>
<body>
<main class="shell" aria-labelledby="page-title">
<section class="hero">
<div class="heroText">
<p class="sectionCode">Public board</p>
<h1 id="page-title">Доска позора</h1>
<p class="summary">тут только самое позорное.</p>
</div>
<aside class="caseTag" aria-label="Board status">
<span>{boardStamp}</span>
<strong>Elysia API</strong>
<a class="loginButton" href={adminUrl}>Login</a>
<small>admin / cards</small>
</aside>
</section>
<section class="board" aria-label="Published cards">
{boardItems.length === 0 && <div class="notice">В админке пока нет опубликованных карточек.</div>}
{
boardItems.map((item, index) => {
return (
<article class="photoCard">
{item.image?.url && <img src={item.image.url} alt={item.image.alt} loading={index < 3 ? "eager" : "lazy"} />}
<div class="caption">
<span>#{String(index + 1).padStart(2, "0")}</span>
<strong>{item.title}</strong>
</div>
{item.comment && <p class="cardComment">{item.comment}</p>}
</article>
);
})
}
</section>
</main>
</body>
</html>
+321
View File
@@ -0,0 +1,321 @@
/* Hallmark · black-and-white editorial board */
:root {
--color-black: #000000;
--color-ink: #f7f7f7;
--color-paper: #090909;
--color-panel: #101010;
--color-panel-raised: #171717;
--color-line: #f7f7f7;
--color-line-soft: #5d5d5d;
--color-muted: #a8a8a8;
--color-faint: #2a2a2a;
--shadow-hard: 10px 10px 0 var(--color-ink);
--shadow-card: 6px 6px 0 var(--color-ink);
--font-display: Georgia, "Times New Roman", serif;
--font-body: Georgia, "Times New Roman", serif;
--font-mono: "Courier New", monospace;
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
overflow-x: clip;
color-scheme: dark;
background:
linear-gradient(90deg, var(--color-faint) 1px, transparent 1px) 0 0 / 32px 32px,
linear-gradient(var(--color-faint) 1px, transparent 1px) 0 0 / 32px 32px,
var(--color-black);
}
body {
min-height: 100vh;
margin: 0;
overflow-x: clip;
background: var(--color-black);
color: var(--color-ink);
font-family: var(--font-body);
}
a {
color: inherit;
}
.shell {
width: min(1480px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 60px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 360px);
min-height: 310px;
border: 2px solid var(--color-line);
background: var(--color-panel);
color: var(--color-ink);
box-shadow: var(--shadow-hard);
position: relative;
overflow: clip;
}
.hero::before {
content: "";
position: absolute;
inset: 16px;
border: 1px dashed var(--color-line-soft);
pointer-events: none;
}
.hero::after {
content: "";
position: absolute;
inset: auto 0 0;
height: 12px;
background:
repeating-linear-gradient(
90deg,
var(--color-ink) 0,
var(--color-ink) 20px,
var(--color-black) 20px,
var(--color-black) 40px
);
}
.heroText {
display: flex;
min-width: 0;
flex-direction: column;
justify-content: space-between;
padding: 34px;
position: relative;
z-index: 1;
}
.sectionCode {
width: max-content;
max-width: 100%;
margin: 0;
padding: 7px 10px;
border: 1px solid var(--color-line);
color: var(--color-black);
background: var(--color-ink);
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
max-width: 940px;
min-width: 0;
margin: 34px 0 0;
font-family: var(--font-display);
font-size: clamp(4rem, 11vw, 10rem);
line-height: 0.78;
letter-spacing: 0;
color: var(--color-ink);
text-transform: uppercase;
overflow-wrap: anywhere;
}
.summary {
max-width: 680px;
margin: 28px 0 0;
color: var(--color-muted);
font-size: 1.08rem;
line-height: 1.52;
}
.caseTag {
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: 18px;
min-height: 100%;
padding: 28px;
border-left: 2px solid var(--color-line);
background: var(--color-black);
color: var(--color-ink);
font-family: var(--font-mono);
text-transform: uppercase;
position: relative;
z-index: 1;
}
.caseTag span {
color: var(--color-black);
background: var(--color-ink);
width: max-content;
max-width: 100%;
padding: 5px 7px;
font-size: 0.84rem;
font-weight: 900;
}
.caseTag strong {
align-self: center;
overflow-wrap: anywhere;
font-size: 1.35rem;
line-height: 1.08;
}
.loginButton {
display: inline-grid;
place-items: center;
width: 100%;
min-height: 46px;
border: 2px solid var(--color-line);
background: var(--color-ink);
color: var(--color-black);
font-family: var(--font-mono);
font-size: 0.88rem;
font-weight: 900;
letter-spacing: 0;
text-decoration: none;
text-transform: uppercase;
box-shadow: 5px 5px 0 var(--color-line-soft);
transition: box-shadow 160ms ease, background 160ms ease, color 160ms ease;
}
.loginButton:hover,
.loginButton:focus-visible {
background: var(--color-black);
color: var(--color-ink);
outline: 0;
box-shadow: 7px 7px 0 var(--color-ink);
}
.caseTag small {
color: var(--color-muted);
font-size: 0.78rem;
font-weight: 900;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
gap: 28px;
align-items: stretch;
min-height: 240px;
padding: 42px 0 12px;
}
.notice {
grid-column: 1 / -1;
padding: 24px;
border: 2px solid var(--color-line);
background: var(--color-panel);
box-shadow: var(--shadow-card);
color: var(--color-muted);
font-size: 1rem;
}
.photoCard {
display: grid;
align-content: start;
min-height: 100%;
border: 2px solid var(--color-line);
background: var(--color-panel);
box-shadow: var(--shadow-card);
transition: box-shadow 160ms ease, background 160ms ease;
}
.photoCard:hover {
background: var(--color-panel-raised);
box-shadow: 9px 9px 0 var(--color-ink);
}
.photoCard img {
display: block;
width: 100%;
height: auto;
border-bottom: 2px solid var(--color-line);
background: var(--color-black);
}
.caption {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 12px;
border-bottom: 1px solid var(--color-line-soft);
font-family: var(--font-mono);
}
.caption span {
padding: 4px 6px;
background: var(--color-ink);
color: var(--color-black);
font-size: 0.78rem;
font-weight: 900;
}
.caption strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-ink);
font-size: 0.92rem;
}
.cardComment {
margin: 0;
padding: 12px;
color: var(--color-muted);
font-size: 0.98rem;
line-height: 1.42;
}
@media (max-width: 840px) {
.shell {
width: min(100% - 20px, 720px);
padding-top: 14px;
}
.hero {
grid-template-columns: minmax(0, 1fr);
}
.heroText {
padding: 26px 22px 30px;
}
h1 {
font-size: clamp(3.2rem, 17vw, 6rem);
}
.summary {
font-size: 1rem;
}
.caseTag {
min-height: 132px;
border-top: 2px solid var(--color-line);
border-left: 0;
}
.board {
padding-top: 28px;
}
}
@media (max-width: 520px) {
.shell {
width: min(100% - 16px, 420px);
}
.hero,
.photoCard,
.notice {
box-shadow: 5px 5px 0 var(--color-ink);
}
.board {
grid-template-columns: minmax(0, 1fr);
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": "."
}
}
-5
View File
@@ -1,5 +0,0 @@
export const boardConfig = {
repo: "dexxdbg/pozor",
photosPath: "img",
branch: "codex/initial-shame-board"
} as const;
+608 -386
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname
});
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
export default eslintConfig;
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-22
View File
@@ -1,22 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "raw.githubusercontent.com"
},
{
protocol: "https",
hostname: "github.com"
},
{
protocol: "https",
hostname: "avatars.githubusercontent.com"
}
]
}
};
export default nextConfig;
+11 -16
View File
@@ -1,24 +1,19 @@
{ {
"name": "pozor-board", "name": "pozor",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"workspaces": [
"apps/*"
],
"scripts": { "scripts": {
"dev": "next dev", "dev": "concurrently -n site,api -c white,gray \"npm.cmd run dev --workspace @pozor/site\" \"npm.cmd run dev --workspace @pozor/api\"",
"build": "next build", "dev:site": "npm.cmd run dev --workspace @pozor/site",
"start": "next start", "dev:api": "npm.cmd run dev --workspace @pozor/api",
"lint": "next lint" "build": "npm.cmd run build --workspace @pozor/site && npm.cmd run build --workspace @pozor/api",
}, "build:site": "npm.cmd run build --workspace @pozor/site",
"dependencies": { "build:api": "npm.cmd run build --workspace @pozor/api"
"next": "15.3.4",
"react": "19.0.0",
"react-dom": "19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.15.29", "concurrently": "^9.2.1"
"@types/react": "19.0.12",
"@types/react-dom": "19.0.4",
"eslint": "9.28.0",
"eslint-config-next": "15.3.4",
"typescript": "5.8.3"
} }
} }
-13
View File
@@ -1,13 +0,0 @@
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="ru">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
+6 -19
View File
@@ -1,24 +1,11 @@
{ {
"compilerOptions": { "files": [],
"target": "ES2017", "references": [
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{ {
"name": "next" "path": "./apps/cms"
},
{
"path": "./apps/site"
} }
] ]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
} }