Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9de4992da5 | |||
| 8e2f75aa77 |
@@ -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/
|
||||
.next/
|
||||
.astro/
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
@@ -14,6 +15,15 @@ coverage/
|
||||
*.obj
|
||||
*.pdb
|
||||
.env*
|
||||
media/
|
||||
payload.db*
|
||||
apps/**/node_modules/
|
||||
apps/**/.next/
|
||||
apps/**/.astro/
|
||||
apps/**/dist/
|
||||
apps/**/media/
|
||||
apps/**/payload.db*
|
||||
apps/api/.data/
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
|
||||
@@ -1,37 +1,54 @@
|
||||
# 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.
|
||||
- Shows the images as a dark evidence-board style gallery.
|
||||
- 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.
|
||||
- `apps/site`: Astro public board. It reads published cards from the Elysia API.
|
||||
- `apps/api`: Elysia admin/API. It owns cards, uploads, and the `/admin` panel.
|
||||
|
||||
## Local development
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
bun install
|
||||
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
|
||||
NEXT_PUBLIC_GITHUB_REPO=owner/repository
|
||||
NEXT_PUBLIC_GITHUB_PHOTOS_PATH=path/to/images
|
||||
NEXT_PUBLIC_GITHUB_BRANCH=main
|
||||
ADMIN_PASSWORD=
|
||||
API_URL=http://localhost:3001
|
||||
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
|
||||
|
||||
```bash
|
||||
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,483 @@
|
||||
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>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
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,308 @@
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import { Elysia } from "elysia";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { renderAdmin, renderLogin } from "./admin";
|
||||
import {
|
||||
type BoardCard,
|
||||
deleteRemoteImage,
|
||||
localUploadPath,
|
||||
publicCards,
|
||||
readCards,
|
||||
saveImage,
|
||||
storageMode,
|
||||
writeCards
|
||||
} from "./store";
|
||||
|
||||
const adminCookie = "pozor_admin";
|
||||
const port = Number(process.env.PORT || 3001);
|
||||
|
||||
const html = (body: string, init?: ResponseInit) =>
|
||||
new Response(body, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
...(init?.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
const redirect = (to: string, init?: ResponseInit) =>
|
||||
new Response(null, {
|
||||
status: 303,
|
||||
...init,
|
||||
headers: {
|
||||
Location: to,
|
||||
...(init?.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
const json = (body: unknown, init?: ResponseInit) =>
|
||||
Response.json(body, {
|
||||
...init,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
...(init?.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
function parseCookies(request: Request) {
|
||||
return Object.fromEntries(
|
||||
(request.headers.get("cookie") || "")
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const index = part.indexOf("=");
|
||||
return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function authToken() {
|
||||
const password = getAdminPassword();
|
||||
if (!password) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const data = new TextEncoder().encode(password);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function isAuthed(request: Request) {
|
||||
const token = await authToken();
|
||||
return Boolean(token) && parseCookies(request)[adminCookie] === token;
|
||||
}
|
||||
|
||||
function getAdminPassword() {
|
||||
return process.env.ADMIN_PASSWORD?.trim() || "";
|
||||
}
|
||||
|
||||
async function cardFromForm(form: FormData, existing?: BoardCard): Promise<BoardCard> {
|
||||
const now = new Date().toISOString();
|
||||
const title = String(form.get("title") || "").trim();
|
||||
const comment = String(form.get("comment") || "").trim();
|
||||
let imageUrl = String(form.get("imageUrl") || "").trim();
|
||||
const file = form.get("image");
|
||||
|
||||
if (file instanceof File && file.size > 0) {
|
||||
if (existing?.imageUrl) {
|
||||
await deleteRemoteImage(existing.imageUrl);
|
||||
}
|
||||
imageUrl = await saveImage(file);
|
||||
}
|
||||
|
||||
return {
|
||||
id: existing?.id || crypto.randomUUID(),
|
||||
title,
|
||||
comment,
|
||||
imageUrl,
|
||||
published: form.get("published") === "on",
|
||||
createdAt: existing?.createdAt || now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
async function renderAdminWithMessage(message: string, status = 500) {
|
||||
return html(renderAdmin(await readCards(), { storageMode, message }), { status });
|
||||
}
|
||||
|
||||
const app = new Elysia()
|
||||
.use(
|
||||
swagger({
|
||||
path: "/docs",
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Pozor Board API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"API and admin endpoints for the dark-only shame board. Public routes expose published cards; admin routes require the admin session cookie."
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: process.env.PUBLIC_API_URL || `http://localhost:${port}`,
|
||||
description: "API server"
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "public",
|
||||
description: "Published board data"
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
description: "Admin panel and card management"
|
||||
},
|
||||
{
|
||||
name: "uploads",
|
||||
description: "Local development uploads"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
.get("/openapi.json", async ({ request }) => {
|
||||
const url = new URL("/docs/json", request.url);
|
||||
return fetch(url);
|
||||
}, {
|
||||
detail: {
|
||||
tags: ["public"],
|
||||
summary: "OpenAPI JSON alias",
|
||||
description: "Redirects to the generated OpenAPI document."
|
||||
}
|
||||
})
|
||||
.get("/", () => redirect("/admin"), {
|
||||
detail: {
|
||||
tags: ["admin"],
|
||||
summary: "Redirect to admin panel"
|
||||
}
|
||||
})
|
||||
.get("/api/cards", async () => {
|
||||
const cards = publicCards(await readCards());
|
||||
return json({ docs: cards });
|
||||
}, {
|
||||
detail: {
|
||||
tags: ["public"],
|
||||
summary: "List published cards",
|
||||
description: "Returns cards visible on the public Astro board."
|
||||
}
|
||||
})
|
||||
.get("/api/admin/cards", async ({ request }) => {
|
||||
if (!(await isAuthed(request))) {
|
||||
return json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return json({ docs: await readCards() });
|
||||
}, {
|
||||
detail: {
|
||||
tags: ["admin"],
|
||||
summary: "List all cards",
|
||||
description: "Requires a valid admin session cookie."
|
||||
}
|
||||
})
|
||||
.get("/uploads/:filename", async ({ params }) => {
|
||||
try {
|
||||
const file = await readFile(localUploadPath(params.filename));
|
||||
return new Response(file);
|
||||
} catch {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
tags: ["uploads"],
|
||||
summary: "Read local uploaded file",
|
||||
description: "Only used in local file storage mode. Blob uploads return public Blob URLs."
|
||||
}
|
||||
})
|
||||
.get("/admin", async ({ request }) => {
|
||||
if (!(await isAuthed(request))) {
|
||||
return html(renderLogin(getAdminPassword() ? "" : "Set ADMIN_PASSWORD in .env to enable login."));
|
||||
}
|
||||
|
||||
return html(renderAdmin(await readCards(), { storageMode }));
|
||||
}, {
|
||||
detail: {
|
||||
tags: ["admin"],
|
||||
summary: "Admin panel"
|
||||
}
|
||||
})
|
||||
.post("/admin/login", async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const password = String(form.get("password") || "");
|
||||
|
||||
const adminPassword = getAdminPassword();
|
||||
|
||||
if (!adminPassword) {
|
||||
return html(renderLogin("ADMIN_PASSWORD is not set."), { status: 403 });
|
||||
}
|
||||
|
||||
if (adminPassword && password !== adminPassword) {
|
||||
return html(renderLogin("Wrong password."), { status: 401 });
|
||||
}
|
||||
|
||||
return redirect("/admin", {
|
||||
headers: {
|
||||
"Set-Cookie": `${adminCookie}=${await authToken()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`
|
||||
}
|
||||
});
|
||||
}, {
|
||||
detail: {
|
||||
tags: ["admin"],
|
||||
summary: "Login to admin panel",
|
||||
description: "Accepts form data with a password field."
|
||||
}
|
||||
})
|
||||
.post("/admin/logout", () =>
|
||||
redirect("/admin", {
|
||||
headers: {
|
||||
"Set-Cookie": `${adminCookie}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
|
||||
}
|
||||
}), {
|
||||
detail: {
|
||||
tags: ["admin"],
|
||||
summary: "Logout from admin panel"
|
||||
}
|
||||
}
|
||||
)
|
||||
.post("/admin/cards", async ({ request }) => {
|
||||
if (!(await isAuthed(request))) {
|
||||
return redirect("/admin");
|
||||
}
|
||||
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const cards = await readCards();
|
||||
const id = String(form.get("id") || "");
|
||||
const existing = cards.find((card) => card.id === id);
|
||||
const nextCard = await cardFromForm(form, existing);
|
||||
const nextCards = existing
|
||||
? cards.map((card) => (card.id === existing.id ? nextCard : card))
|
||||
: [nextCard, ...cards];
|
||||
|
||||
await writeCards(nextCards);
|
||||
return redirect("/admin");
|
||||
} catch (error) {
|
||||
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card.");
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
tags: ["admin"],
|
||||
summary: "Create or update a card",
|
||||
description: "Requires a valid admin session cookie. Accepts multipart form data."
|
||||
}
|
||||
})
|
||||
.post("/admin/cards/delete", async ({ request }) => {
|
||||
if (!(await isAuthed(request))) {
|
||||
return redirect("/admin");
|
||||
}
|
||||
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get("id") || "");
|
||||
const cards = await readCards();
|
||||
const existing = cards.find((card) => card.id === id);
|
||||
|
||||
if (existing?.imageUrl) {
|
||||
await deleteRemoteImage(existing.imageUrl);
|
||||
}
|
||||
|
||||
await writeCards(cards.filter((card) => card.id !== id));
|
||||
return redirect("/admin");
|
||||
} catch (error) {
|
||||
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card.");
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
tags: ["admin"],
|
||||
summary: "Delete a card",
|
||||
description: "Requires a valid admin session cookie. Accepts form data with an id field."
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
if (process.env.VERCEL !== "1") {
|
||||
app.listen(port);
|
||||
console.log(`Pozor admin: http://localhost:${port}/admin`);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { del, get, put } from "@vercel/blob";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type BoardCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
comment: string;
|
||||
imageUrl: string;
|
||||
published: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const dataBlobPath = "pozor/cards.json";
|
||||
const localDataDir = path.resolve(".data");
|
||||
const localUploadsDir = path.join(localDataDir, "uploads");
|
||||
const localCardsPath = path.join(localDataDir, "cards.json");
|
||||
|
||||
const hasBlobToken = () => Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim());
|
||||
const isVercel = () => process.env.VERCEL === "1";
|
||||
|
||||
export const storageMode = hasBlobToken() ? "Vercel Blob" : "Local file";
|
||||
|
||||
export async function readCards(): Promise<BoardCard[]> {
|
||||
if (hasBlobToken()) {
|
||||
try {
|
||||
const result = await get(dataBlobPath, { access: "private" });
|
||||
if (!result?.stream) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const text = await streamToText(result.stream);
|
||||
const parsed = JSON.parse(text) as BoardCard[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await readFile(localCardsPath, "utf8");
|
||||
const parsed = JSON.parse(text) as BoardCard[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeCards(cards: BoardCard[]) {
|
||||
const content = JSON.stringify(cards, null, 2);
|
||||
|
||||
if (hasBlobToken()) {
|
||||
await put(dataBlobPath, content, {
|
||||
access: "private",
|
||||
contentType: "application/json",
|
||||
allowOverwrite: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
assertWritableLocalStorage();
|
||||
await mkdir(localDataDir, { recursive: true });
|
||||
await writeFile(localCardsPath, content, "utf8");
|
||||
}
|
||||
|
||||
export async function saveImage(file: File) {
|
||||
const safeName = safeFileName(file.name || "image");
|
||||
const pathname = `pozor/images/${Date.now()}-${safeName}`;
|
||||
|
||||
if (hasBlobToken()) {
|
||||
const blob = await put(pathname, file, {
|
||||
access: "public",
|
||||
contentType: file.type || "application/octet-stream"
|
||||
});
|
||||
return blob.url;
|
||||
}
|
||||
|
||||
assertWritableLocalStorage();
|
||||
await mkdir(localUploadsDir, { recursive: true });
|
||||
const localName = `${Date.now()}-${safeName}`;
|
||||
const localPath = path.join(localUploadsDir, localName);
|
||||
await writeFile(localPath, Buffer.from(await file.arrayBuffer()));
|
||||
return `/uploads/${localName}`;
|
||||
}
|
||||
|
||||
export async function deleteRemoteImage(imageUrl: string) {
|
||||
if (!hasBlobToken() || !imageUrl.includes(".blob.vercel-storage.com/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await del(imageUrl);
|
||||
} catch {
|
||||
// Image deletion should not block card edits.
|
||||
}
|
||||
}
|
||||
|
||||
export function localUploadPath(filename: string) {
|
||||
return path.join(localUploadsDir, safeFileName(filename));
|
||||
}
|
||||
|
||||
function assertWritableLocalStorage() {
|
||||
if (!isVercel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"BLOB_READ_WRITE_TOKEN is not set for the API deployment. Vercel functions cannot write to .data; connect Vercel Blob to the API project and redeploy."
|
||||
);
|
||||
}
|
||||
|
||||
export function publicCards(cards: BoardCard[]) {
|
||||
return cards
|
||||
.filter((card) => card.published)
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
||||
}
|
||||
|
||||
function safeFileName(name: string) {
|
||||
return name.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-");
|
||||
}
|
||||
|
||||
async function streamToText(stream: ReadableStream<Uint8Array>) {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -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",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.3.4",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
"dev": "concurrently -n site,api -c white,gray \"npm.cmd run dev --workspace @pozor/site\" \"npm.cmd run dev --workspace @pozor/api\"",
|
||||
"dev:site": "npm.cmd run dev --workspace @pozor/site",
|
||||
"dev:api": "npm.cmd run dev --workspace @pozor/api",
|
||||
"build": "npm.cmd run build --workspace @pozor/site && npm.cmd run build --workspace @pozor/api",
|
||||
"build:site": "npm.cmd run build --workspace @pozor/site",
|
||||
"build:api": "npm.cmd run build --workspace @pozor/api"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.15.29",
|
||||
"@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"
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./apps/cms"
|
||||
},
|
||||
{
|
||||
"path": "./apps/site"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user