15 Commits

Author SHA1 Message Date
dexx 5033ce8b4d Prevent data loss from read errors and concurrent writes
readCards now throws on fetch/parse failures instead of returning []
so a broken read can never trigger an overwrite of valid data.
Added a write lock to serialize card mutations within a single instance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:53:17 +03:00
dexx 79460fb47d Prevent double-submit on admin forms
Disables submit buttons after first click to prevent race conditions
that could corrupt card data via concurrent writes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:27:52 +03:00
dexx d510bd944b Fix readCards for public blob store
The get() function doesn't work with relative paths on public stores.
Use list() to find the blob URL, then fetch it directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:26:14 +03:00
dexx 97e864bd06 Add lightbox for full-size image viewing
Clicking a card image now opens it full-screen in an overlay.
Click anywhere or press Escape to close.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:16:54 +03:00
dexx 27b36c36c4 Switch board to client-side fetching
Cards now load dynamically on each page visit instead of at build time,
so new cards appear immediately without redeploying the site.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:08:15 +03:00
dexx ea273a4ac4 Fix blob access mode for public store
The new Vercel Blob store is public, so private access is not allowed.
Switch cards read/write to public access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 22:00:33 +03:00
dexx 34eb42bb09 Merge branch 'elysia-dev' 2026-06-05 21:48:55 +03:00
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 c2e7b4704c Merge pull request #4 from dexxdbg/elysia-dev
Replace Payload with Elysia admin
2026-06-05 21:23:23 +03:00
dexx e3b0508871 Merge branch 'main' into elysia-dev 2026-06-05 21:22:00 +03:00
dexx 8e2f75aa77 Replace Payload with Elysia admin
Co-authored-by: Codex <codex@openai.com>
2026-06-05 21:12:48 +03:00
dexx e0d7641acc Merge pull request #3 from dexxdbg/vercel/react-server-components-cve-vu-j9owyy
Fix React Server Components CVE vulnerabilities
2026-06-05 12:54:47 +03:00
Vercel 45d5bfd2b6 Fix React Server Components CVE vulnerabilities
Updated dependencies to fix Next.js and React CVE vulnerabilities.

The fix-react2shell-next tool automatically updated the following packages to their secure versions:
- next
- react-server-dom-webpack
- react-server-dom-parcel  
- react-server-dom-turbopack

All package.json files have been scanned and vulnerable versions have been patched to the correct fixed versions based on the official React advisory.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2026-06-05 00:08:12 +00:00
dexx acd9eaef49 Merge pull request #2 from dexxdbg/codex/initial-shame-board
configure board source in typescript
2026-06-05 03:05:07 +03:00
dexx 9217b7f139 Merge pull request #1 from dexxdbg/codex/initial-shame-board
[codex] initial shame board
2026-06-05 02:56:31 +03:00
27 changed files with 2146 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"
}
}
+493
View File
@@ -0,0 +1,493 @@
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>
<script>
document.querySelectorAll("form").forEach(f => {
f.addEventListener("submit", () => {
f.querySelectorAll("button[type=submit]").forEach(b => {
b.disabled = true;
b.textContent = "Wait...";
});
});
});
</script>
`
);
}
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);
}
}
`;
+313
View File
@@ -0,0 +1,313 @@
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,
withWriteLock,
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 {
await withWriteLock(async () => {
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 {
await withWriteLock(async () => {
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`);
}
+126
View File
@@ -0,0 +1,126 @@
import { del, list, 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";
let writeLock: Promise<void> = Promise.resolve();
export function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
const next = writeLock.then(fn, fn);
writeLock = next.then(() => {}, () => {});
return next;
}
export const storageMode = hasBlobToken() ? "Vercel Blob" : "Local file";
export async function readCards(): Promise<BoardCard[]> {
if (hasBlobToken()) {
const { blobs } = await list({ prefix: dataBlobPath, limit: 1 });
if (blobs.length === 0) return [];
const res = await fetch(blobs[0].url);
if (!res.ok) throw new Error(`Blob read failed: ${res.status}`);
const parsed = (await res.json()) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
}
try {
const text = await readFile(localCardsPath, "utf8");
const parsed = JSON.parse(text) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
} catch (err: any) {
if (err?.code === "ENOENT") return [];
throw err;
}
}
export async function writeCards(cards: BoardCard[]) {
const content = JSON.stringify(cards, null, 2);
if (hasBlobToken()) {
await put(dataBlobPath, content, {
access: "public",
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, "-");
}
+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" />
+153
View File
@@ -0,0 +1,153 @@
---
import "../styles/global.css";
const apiUrl = (
import.meta.env.API_URL ||
import.meta.env.PUBLIC_API_URL ||
"http://localhost:3001"
).replace(/\/$/, "");
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 id="board-stamp">Loading...</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" id="board">
<div class="notice">Загрузка...</div>
</section>
</main>
<div class="lightbox" id="lightbox" aria-hidden="true">
<button class="lightbox-close" aria-label="Close">&times;</button>
<img id="lightbox-img" src="" alt="" />
</div>
<style>
.lightbox {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.9);
align-items: center;
justify-content: center;
cursor: zoom-out;
}
.lightbox.open {
display: flex;
}
.lightbox img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 4px;
}
.lightbox-close {
position: absolute;
top: 16px;
right: 24px;
font-size: 2.5rem;
color: #fff;
background: none;
border: none;
cursor: pointer;
line-height: 1;
}
.photoCard img {
cursor: zoom-in;
}
</style>
<script define:vars={{ apiUrl }}>
const API = apiUrl;
function absolutize(url) {
if (/^https?:\/\//i.test(url)) return url;
return `${API}${url.startsWith("/") ? "" : "/"}${url}`;
}
async function loadCards() {
const board = document.getElementById("board");
const stamp = document.getElementById("board-stamp");
try {
const res = await fetch(`${API}/api/cards`);
if (!res.ok) throw new Error();
const data = await res.json();
const cards = data.docs ?? [];
stamp.textContent = cards.length === 0
? "Empty"
: `${cards.length} filed`;
if (cards.length === 0) {
board.innerHTML =
'<div class="notice">В админке пока нет опубликованных карточек.</div>';
return;
}
board.innerHTML = cards.map((card, i) => {
const img = card.imageUrl
? `<img src="${absolutize(card.imageUrl)}" alt="${card.title.replace(/"/g, "&quot;")}" loading="${i < 3 ? "eager" : "lazy"}" />`
: "";
const comment = card.comment
? `<p class="cardComment">${card.comment.replace(/</g, "&lt;")}</p>`
: "";
return `<article class="photoCard">${img}<div class="caption"><span>#${String(i + 1).padStart(2, "0")}</span><strong>${card.title.replace(/</g, "&lt;")}</strong></div>${comment}</article>`;
}).join("");
} catch {
board.innerHTML =
'<div class="notice">Не удалось загрузить карточки.</div>';
stamp.textContent = "Error";
}
}
loadCards();
const lightbox = document.getElementById("lightbox");
const lightboxImg = document.getElementById("lightbox-img");
document.getElementById("board").addEventListener("click", (e) => {
const img = e.target.closest(".photoCard img");
if (!img) return;
lightboxImg.src = img.src;
lightboxImg.alt = img.alt;
lightbox.classList.add("open");
lightbox.setAttribute("aria-hidden", "false");
});
lightbox.addEventListener("click", () => {
lightbox.classList.remove("open");
lightbox.setAttribute("aria-hidden", "true");
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && lightbox.classList.contains("open")) {
lightbox.classList.remove("open");
lightbox.setAttribute("aria-hidden", "true");
}
});
</script>
</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>
);
}
+9 -22
View File
@@ -1,24 +1,11 @@
{ {
"compilerOptions": { "files": [],
"target": "ES2017", "references": [
"lib": ["dom", "dom.iterable", "esnext"], {
"allowJs": false, "path": "./apps/cms"
"skipLibCheck": true, },
"strict": true, {
"noEmit": true, "path": "./apps/site"
"esModuleInterop": true, }
"module": "esnext", ]
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
} }