Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5033ce8b4d | |||
| 79460fb47d | |||
| d510bd944b | |||
| 97e864bd06 | |||
| 27b36c36c4 | |||
| ea273a4ac4 | |||
| 34eb42bb09 | |||
| 9de4992da5 | |||
| c2e7b4704c | |||
| e3b0508871 | |||
| 8e2f75aa77 | |||
| e0d7641acc | |||
| 45d5bfd2b6 | |||
| acd9eaef49 | |||
| 9217b7f139 |
@@ -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
@@ -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/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import type { BoardCard } from "./store";
|
||||||
|
|
||||||
|
const escapeHtml = (value: unknown) =>
|
||||||
|
String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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, "-");
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["bun"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
|
"bunVersion": "1.x"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
output: "static"
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
|
/// <reference types="astro/client" />
|
||||||
@@ -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">×</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, """)}" loading="${i < 3 ? "eager" : "lazy"}" />`
|
||||||
|
: "";
|
||||||
|
const comment = card.comment
|
||||||
|
? `<p class="cardComment">${card.comment.replace(/</g, "<")}</p>`
|
||||||
|
: "";
|
||||||
|
return `<article class="photoCard">${img}<div class="caption"><span>#${String(i + 1).padStart(2, "0")}</span><strong>${card.title.replace(/</g, "<")}</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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export const boardConfig = {
|
|
||||||
repo: "dexxdbg/pozor",
|
|
||||||
photosPath: "img",
|
|
||||||
branch: "codex/initial-shame-board"
|
|
||||||
} as const;
|
|
||||||
@@ -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;
|
|
||||||
Vendored
-6
@@ -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.
|
|
||||||
@@ -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
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user