Replace Payload with Elysia admin

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
dexx
2026-06-05 21:12:48 +03:00
parent 92cf1eaa63
commit 8e2f75aa77
27 changed files with 2066 additions and 989 deletions
+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"
}
}
+468
View File
@@ -0,0 +1,468 @@
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 }) {
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>
<section class="workbench">
<form class="editor" method="post" action="/admin/cards" enctype="multipart/form-data">
<p class="stamp">New card</p>
<label>
<span>Title</span>
<input name="title" required />
</label>
<label>
<span>Comment</span>
<textarea name="comment" rows="4"></textarea>
</label>
<label>
<span>Image URL</span>
<input name="imageUrl" inputmode="url" />
</label>
<label>
<span>Upload image</span>
<input name="image" type="file" accept="image/*" />
</label>
<label class="checkline">
<input name="published" type="checkbox" checked />
<span>Published</span>
</label>
<button type="submit">Create</button>
</form>
<section class="cards" aria-label="Cards">
${
sorted.length === 0
? `<div class="empty">No cards yet.</div>`
: sorted.map(renderCardEditor).join("")
}
</section>
</section>
</main>
`
);
}
function renderCardEditor(card: BoardCard) {
return `
<article class="cardEditor">
${card.imageUrl ? `<img src="${escapeHtml(card.imageUrl)}" alt="${escapeHtml(card.title)}" />` : `<div class="imageEmpty">No image</div>`}
<form method="post" action="/admin/cards" enctype="multipart/form-data">
<input type="hidden" name="id" value="${escapeHtml(card.id)}" />
<label>
<span>Title</span>
<input name="title" required value="${escapeHtml(card.title)}" />
</label>
<label>
<span>Comment</span>
<textarea name="comment" rows="3">${escapeHtml(card.comment)}</textarea>
</label>
<label>
<span>Image URL</span>
<input name="imageUrl" value="${escapeHtml(card.imageUrl)}" />
</label>
<label>
<span>Replace image</span>
<input name="image" type="file" accept="image/*" />
</label>
<label class="checkline">
<input name="published" type="checkbox" ${card.published ? "checked" : ""} />
<span>Published</span>
</label>
<div class="rowActions">
<button type="submit">Save</button>
<button type="submit" formaction="/admin/cards/delete" class="danger">Delete</button>
</div>
</form>
</article>
`;
}
function pageShell(title: string, body: string) {
return `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>${escapeHtml(title)} · Pozor Admin</title>
<style>${adminCss}</style>
</head>
<body>${body}</body>
</html>`;
}
const adminCss = `
/* Hallmark · pre-emit critique: P5 H4 E4 S5 R5 V4 */
:root {
--color-black: #000000;
--color-ink: #f7f7f7;
--color-panel: #111111;
--color-panel-raised: #181818;
--color-muted: #a7a7a7;
--color-line: #f7f7f7;
--color-line-soft: #565656;
--color-danger: #ffffff;
--shadow-hard: 8px 8px 0 var(--color-line);
--font-display: Georgia, "Times New Roman", serif;
--font-body: Georgia, "Times New Roman", serif;
--font-mono: "Courier New", monospace;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
overflow-x: clip;
background: var(--color-black);
color: var(--color-ink);
color-scheme: dark;
font-family: var(--font-body);
}
button,
input,
textarea {
font: inherit;
}
button,
input,
textarea {
border-radius: 0;
}
.adminShell,
.loginShell {
width: min(1480px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 64px;
}
.loginShell {
display: grid;
min-height: 100vh;
place-items: center;
}
.loginPanel,
.mast,
.editor,
.cardEditor,
.empty {
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 {
padding: 18px;
}
.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);
}
}
`;
+296
View File
@@ -0,0 +1,296 @@
import { swagger } from "@elysiajs/swagger";
import { Elysia } from "elysia";
import { readFile } from "node:fs/promises";
import { renderAdmin, renderLogin } from "./admin";
import {
type BoardCard,
deleteRemoteImage,
localUploadPath,
publicCards,
readCards,
saveImage,
storageMode,
writeCards
} from "./store";
const adminCookie = "pozor_admin";
const port = Number(process.env.PORT || 3001);
const html = (body: string, init?: ResponseInit) =>
new Response(body, {
...init,
headers: {
"Content-Type": "text/html; charset=utf-8",
...(init?.headers || {})
}
});
const redirect = (to: string, init?: ResponseInit) =>
new Response(null, {
status: 303,
...init,
headers: {
Location: to,
...(init?.headers || {})
}
});
const json = (body: unknown, init?: ResponseInit) =>
Response.json(body, {
...init,
headers: {
"Access-Control-Allow-Origin": "*",
...(init?.headers || {})
}
});
function parseCookies(request: Request) {
return Object.fromEntries(
(request.headers.get("cookie") || "")
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const index = part.indexOf("=");
return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))];
})
);
}
async function authToken() {
const password = getAdminPassword();
if (!password) {
return "";
}
const data = new TextEncoder().encode(password);
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function isAuthed(request: Request) {
const token = await authToken();
return Boolean(token) && parseCookies(request)[adminCookie] === token;
}
function getAdminPassword() {
return process.env.ADMIN_PASSWORD?.trim() || "";
}
async function cardFromForm(form: FormData, existing?: BoardCard): Promise<BoardCard> {
const now = new Date().toISOString();
const title = String(form.get("title") || "").trim();
const comment = String(form.get("comment") || "").trim();
let imageUrl = String(form.get("imageUrl") || "").trim();
const file = form.get("image");
if (file instanceof File && file.size > 0) {
if (existing?.imageUrl) {
await deleteRemoteImage(existing.imageUrl);
}
imageUrl = await saveImage(file);
}
return {
id: existing?.id || crypto.randomUUID(),
title,
comment,
imageUrl,
published: form.get("published") === "on",
createdAt: existing?.createdAt || now,
updatedAt: now
};
}
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");
}
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");
}, {
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");
}
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");
}, {
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`);
}
+121
View File
@@ -0,0 +1,121 @@
import { del, get, put } from "@vercel/blob";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
export type BoardCard = {
id: string;
title: string;
comment: string;
imageUrl: string;
published: boolean;
createdAt: string;
updatedAt: string;
};
const dataBlobPath = "pozor/cards.json";
const localDataDir = path.resolve(".data");
const localUploadsDir = path.join(localDataDir, "uploads");
const localCardsPath = path.join(localDataDir, "cards.json");
export const storageMode = process.env.BLOB_READ_WRITE_TOKEN ? "Vercel Blob" : "Local file";
export async function readCards(): Promise<BoardCard[]> {
if (process.env.BLOB_READ_WRITE_TOKEN) {
try {
const result = await get(dataBlobPath, { access: "private" });
if (!result?.stream) {
return [];
}
const text = await streamToText(result.stream);
const parsed = JSON.parse(text) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
try {
const text = await readFile(localCardsPath, "utf8");
const parsed = JSON.parse(text) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export async function writeCards(cards: BoardCard[]) {
const content = JSON.stringify(cards, null, 2);
if (process.env.BLOB_READ_WRITE_TOKEN) {
await put(dataBlobPath, content, {
access: "private",
contentType: "application/json",
allowOverwrite: true
});
return;
}
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 (process.env.BLOB_READ_WRITE_TOKEN) {
const blob = await put(pathname, file, {
access: "public",
contentType: file.type || "application/octet-stream"
});
return blob.url;
}
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 (!process.env.BLOB_READ_WRITE_TOKEN || !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));
}
export function publicCards(cards: BoardCard[]) {
return cards
.filter((card) => card.published)
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
}
function safeFileName(name: string) {
return name.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-");
}
async function streamToText(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
}
return Buffer.concat(chunks).toString("utf8");
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src/**/*.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"bunVersion": "1.x"
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "astro/config";
export default defineConfig({
output: "static"
});
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@pozor/site",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "cross-env ASTRO_TELEMETRY_DISABLED=1 node ./node_modules/astro/astro.js dev --host 0.0.0.0 --port 4321",
"build": "cross-env ASTRO_TELEMETRY_DISABLED=1 node ./node_modules/astro/astro.js build",
"preview": "cross-env ASTRO_TELEMETRY_DISABLED=1 node ./node_modules/astro/astro.js preview --host 0.0.0.0 --port 4321"
},
"dependencies": {
"astro": "^5.8.2"
},
"devDependencies": {
"cross-env": "^10.1.0",
"typescript": "5.8.3"
}
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
+120
View File
@@ -0,0 +1,120 @@
---
import "../styles/global.css";
type BoardCard = {
id: string | number;
title: string;
comment?: string | null;
imageUrl?: string | null;
published?: boolean;
};
type ApiResponse = {
docs?: BoardCard[];
};
type BoardItem = {
id: string | number;
title: string;
comment?: string | null;
image: {
alt: string;
url: string;
} | null;
};
const apiUrl = (
import.meta.env.API_URL ||
import.meta.env.PUBLIC_API_URL ||
"http://localhost:3001"
).replace(/\/$/, "");
async function getCards() {
try {
const response = await fetch(`${apiUrl}/api/cards`);
if (!response.ok) {
return [];
}
const data = (await response.json()) as ApiResponse;
return data.docs ?? [];
} catch {
return [];
}
}
function absolutize(url: string) {
if (/^https?:\/\//i.test(url)) {
return url;
}
return `${apiUrl}${url.startsWith("/") ? "" : "/"}${url}`;
}
function getImage(card: BoardCard) {
if (card.imageUrl) {
return {
alt: card.title,
url: absolutize(card.imageUrl)
};
}
return null;
}
const cards = await getCards();
const boardItems: BoardItem[] = cards.map((card) => ({
id: card.id,
title: card.title,
comment: card.comment,
image: getImage(card)
}));
const boardStamp = boardItems.length === 0 ? "Empty" : `${boardItems.length} filed`;
const adminUrl = `${apiUrl}/admin`;
---
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content="#000000" />
<title>Доска позора</title>
</head>
<body>
<main class="shell" aria-labelledby="page-title">
<section class="hero">
<div class="heroText">
<p class="sectionCode">Public board</p>
<h1 id="page-title">Доска позора</h1>
<p class="summary">тут только самое позорное.</p>
</div>
<aside class="caseTag" aria-label="Board status">
<span>{boardStamp}</span>
<strong>Elysia API</strong>
<a class="loginButton" href={adminUrl}>Login</a>
<small>admin / cards</small>
</aside>
</section>
<section class="board" aria-label="Published cards">
{boardItems.length === 0 && <div class="notice">В админке пока нет опубликованных карточек.</div>}
{
boardItems.map((item, index) => {
return (
<article class="photoCard">
{item.image?.url && <img src={item.image.url} alt={item.image.alt} loading={index < 3 ? "eager" : "lazy"} />}
<div class="caption">
<span>#{String(index + 1).padStart(2, "0")}</span>
<strong>{item.title}</strong>
</div>
{item.comment && <p class="cardComment">{item.comment}</p>}
</article>
);
})
}
</section>
</main>
</body>
</html>
+321
View File
@@ -0,0 +1,321 @@
/* Hallmark · black-and-white editorial board */
:root {
--color-black: #000000;
--color-ink: #f7f7f7;
--color-paper: #090909;
--color-panel: #101010;
--color-panel-raised: #171717;
--color-line: #f7f7f7;
--color-line-soft: #5d5d5d;
--color-muted: #a8a8a8;
--color-faint: #2a2a2a;
--shadow-hard: 10px 10px 0 var(--color-ink);
--shadow-card: 6px 6px 0 var(--color-ink);
--font-display: Georgia, "Times New Roman", serif;
--font-body: Georgia, "Times New Roman", serif;
--font-mono: "Courier New", monospace;
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
overflow-x: clip;
color-scheme: dark;
background:
linear-gradient(90deg, var(--color-faint) 1px, transparent 1px) 0 0 / 32px 32px,
linear-gradient(var(--color-faint) 1px, transparent 1px) 0 0 / 32px 32px,
var(--color-black);
}
body {
min-height: 100vh;
margin: 0;
overflow-x: clip;
background: var(--color-black);
color: var(--color-ink);
font-family: var(--font-body);
}
a {
color: inherit;
}
.shell {
width: min(1480px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 60px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 360px);
min-height: 310px;
border: 2px solid var(--color-line);
background: var(--color-panel);
color: var(--color-ink);
box-shadow: var(--shadow-hard);
position: relative;
overflow: clip;
}
.hero::before {
content: "";
position: absolute;
inset: 16px;
border: 1px dashed var(--color-line-soft);
pointer-events: none;
}
.hero::after {
content: "";
position: absolute;
inset: auto 0 0;
height: 12px;
background:
repeating-linear-gradient(
90deg,
var(--color-ink) 0,
var(--color-ink) 20px,
var(--color-black) 20px,
var(--color-black) 40px
);
}
.heroText {
display: flex;
min-width: 0;
flex-direction: column;
justify-content: space-between;
padding: 34px;
position: relative;
z-index: 1;
}
.sectionCode {
width: max-content;
max-width: 100%;
margin: 0;
padding: 7px 10px;
border: 1px solid var(--color-line);
color: var(--color-black);
background: var(--color-ink);
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
max-width: 940px;
min-width: 0;
margin: 34px 0 0;
font-family: var(--font-display);
font-size: clamp(4rem, 11vw, 10rem);
line-height: 0.78;
letter-spacing: 0;
color: var(--color-ink);
text-transform: uppercase;
overflow-wrap: anywhere;
}
.summary {
max-width: 680px;
margin: 28px 0 0;
color: var(--color-muted);
font-size: 1.08rem;
line-height: 1.52;
}
.caseTag {
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: 18px;
min-height: 100%;
padding: 28px;
border-left: 2px solid var(--color-line);
background: var(--color-black);
color: var(--color-ink);
font-family: var(--font-mono);
text-transform: uppercase;
position: relative;
z-index: 1;
}
.caseTag span {
color: var(--color-black);
background: var(--color-ink);
width: max-content;
max-width: 100%;
padding: 5px 7px;
font-size: 0.84rem;
font-weight: 900;
}
.caseTag strong {
align-self: center;
overflow-wrap: anywhere;
font-size: 1.35rem;
line-height: 1.08;
}
.loginButton {
display: inline-grid;
place-items: center;
width: 100%;
min-height: 46px;
border: 2px solid var(--color-line);
background: var(--color-ink);
color: var(--color-black);
font-family: var(--font-mono);
font-size: 0.88rem;
font-weight: 900;
letter-spacing: 0;
text-decoration: none;
text-transform: uppercase;
box-shadow: 5px 5px 0 var(--color-line-soft);
transition: box-shadow 160ms ease, background 160ms ease, color 160ms ease;
}
.loginButton:hover,
.loginButton:focus-visible {
background: var(--color-black);
color: var(--color-ink);
outline: 0;
box-shadow: 7px 7px 0 var(--color-ink);
}
.caseTag small {
color: var(--color-muted);
font-size: 0.78rem;
font-weight: 900;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
gap: 28px;
align-items: stretch;
min-height: 240px;
padding: 42px 0 12px;
}
.notice {
grid-column: 1 / -1;
padding: 24px;
border: 2px solid var(--color-line);
background: var(--color-panel);
box-shadow: var(--shadow-card);
color: var(--color-muted);
font-size: 1rem;
}
.photoCard {
display: grid;
align-content: start;
min-height: 100%;
border: 2px solid var(--color-line);
background: var(--color-panel);
box-shadow: var(--shadow-card);
transition: box-shadow 160ms ease, background 160ms ease;
}
.photoCard:hover {
background: var(--color-panel-raised);
box-shadow: 9px 9px 0 var(--color-ink);
}
.photoCard img {
display: block;
width: 100%;
height: auto;
border-bottom: 2px solid var(--color-line);
background: var(--color-black);
}
.caption {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 12px;
border-bottom: 1px solid var(--color-line-soft);
font-family: var(--font-mono);
}
.caption span {
padding: 4px 6px;
background: var(--color-ink);
color: var(--color-black);
font-size: 0.78rem;
font-weight: 900;
}
.caption strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-ink);
font-size: 0.92rem;
}
.cardComment {
margin: 0;
padding: 12px;
color: var(--color-muted);
font-size: 0.98rem;
line-height: 1.42;
}
@media (max-width: 840px) {
.shell {
width: min(100% - 20px, 720px);
padding-top: 14px;
}
.hero {
grid-template-columns: minmax(0, 1fr);
}
.heroText {
padding: 26px 22px 30px;
}
h1 {
font-size: clamp(3.2rem, 17vw, 6rem);
}
.summary {
font-size: 1rem;
}
.caseTag {
min-height: 132px;
border-top: 2px solid var(--color-line);
border-left: 0;
}
.board {
padding-top: 28px;
}
}
@media (max-width: 520px) {
.shell {
width: min(100% - 16px, 420px);
}
.hero,
.photoCard,
.notice {
box-shadow: 5px 5px 0 var(--color-ink);
}
.board {
grid-template-columns: minmax(0, 1fr);
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": "."
}
}