Compare commits
8 Commits
c2e7b4704c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5033ce8b4d | |||
| 79460fb47d | |||
| d510bd944b | |||
| 97e864bd06 | |||
| 27b36c36c4 | |||
| ea273a4ac4 | |||
| 34eb42bb09 | |||
| 9de4992da5 |
+28
-3
@@ -29,7 +29,7 @@ export function renderLogin(message = "") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderAdmin(cards: BoardCard[], options: { storageMode: string }) {
|
export function renderAdmin(cards: BoardCard[], options: { storageMode: string; message?: string }) {
|
||||||
const sorted = [...cards].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
const sorted = [...cards].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
||||||
|
|
||||||
return pageShell(
|
return pageShell(
|
||||||
@@ -52,6 +52,12 @@ export function renderAdmin(cards: BoardCard[], options: { storageMode: string }
|
|||||||
</aside>
|
</aside>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
${
|
||||||
|
options.message
|
||||||
|
? `<section class="noticeBanner">${escapeHtml(options.message)}</section>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
<section class="workbench">
|
<section class="workbench">
|
||||||
<form class="editor" method="post" action="/admin/cards" enctype="multipart/form-data">
|
<form class="editor" method="post" action="/admin/cards" enctype="multipart/form-data">
|
||||||
<p class="stamp">New card</p>
|
<p class="stamp">New card</p>
|
||||||
@@ -87,6 +93,16 @@ export function renderAdmin(cards: BoardCard[], options: { storageMode: string }
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll("form").forEach(f => {
|
||||||
|
f.addEventListener("submit", () => {
|
||||||
|
f.querySelectorAll("button[type=submit]").forEach(b => {
|
||||||
|
b.disabled = true;
|
||||||
|
b.textContent = "Wait...";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -201,7 +217,8 @@ textarea {
|
|||||||
.mast,
|
.mast,
|
||||||
.editor,
|
.editor,
|
||||||
.cardEditor,
|
.cardEditor,
|
||||||
.empty {
|
.empty,
|
||||||
|
.noticeBanner {
|
||||||
border: 2px solid var(--color-line);
|
border: 2px solid var(--color-line);
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
box-shadow: var(--shadow-hard);
|
box-shadow: var(--shadow-hard);
|
||||||
@@ -283,10 +300,18 @@ h1 {
|
|||||||
|
|
||||||
.editor,
|
.editor,
|
||||||
.cardEditor,
|
.cardEditor,
|
||||||
.empty {
|
.empty,
|
||||||
|
.noticeBanner {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noticeBanner {
|
||||||
|
margin-top: 28px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 18px;
|
top: 18px;
|
||||||
|
|||||||
+36
-19
@@ -10,6 +10,7 @@ import {
|
|||||||
readCards,
|
readCards,
|
||||||
saveImage,
|
saveImage,
|
||||||
storageMode,
|
storageMode,
|
||||||
|
withWriteLock,
|
||||||
writeCards
|
writeCards
|
||||||
} from "./store";
|
} from "./store";
|
||||||
|
|
||||||
@@ -104,6 +105,10 @@ async function cardFromForm(form: FormData, existing?: BoardCard): Promise<Board
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderAdminWithMessage(message: string, status = 500) {
|
||||||
|
return html(renderAdmin(await readCards(), { storageMode, message }), { status });
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.use(
|
.use(
|
||||||
swagger({
|
swagger({
|
||||||
@@ -246,17 +251,23 @@ const app = new Elysia()
|
|||||||
return redirect("/admin");
|
return redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = await request.formData();
|
try {
|
||||||
const cards = await readCards();
|
await withWriteLock(async () => {
|
||||||
const id = String(form.get("id") || "");
|
const form = await request.formData();
|
||||||
const existing = cards.find((card) => card.id === id);
|
const cards = await readCards();
|
||||||
const nextCard = await cardFromForm(form, existing);
|
const id = String(form.get("id") || "");
|
||||||
const nextCards = existing
|
const existing = cards.find((card) => card.id === id);
|
||||||
? cards.map((card) => (card.id === existing.id ? nextCard : card))
|
const nextCard = await cardFromForm(form, existing);
|
||||||
: [nextCard, ...cards];
|
const nextCards = existing
|
||||||
|
? cards.map((card) => (card.id === existing.id ? nextCard : card))
|
||||||
|
: [nextCard, ...cards];
|
||||||
|
|
||||||
await writeCards(nextCards);
|
await writeCards(nextCards);
|
||||||
return redirect("/admin");
|
});
|
||||||
|
return redirect("/admin");
|
||||||
|
} catch (error) {
|
||||||
|
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card.");
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
detail: {
|
detail: {
|
||||||
tags: ["admin"],
|
tags: ["admin"],
|
||||||
@@ -269,17 +280,23 @@ const app = new Elysia()
|
|||||||
return redirect("/admin");
|
return redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = await request.formData();
|
try {
|
||||||
const id = String(form.get("id") || "");
|
await withWriteLock(async () => {
|
||||||
const cards = await readCards();
|
const form = await request.formData();
|
||||||
const existing = cards.find((card) => card.id === id);
|
const id = String(form.get("id") || "");
|
||||||
|
const cards = await readCards();
|
||||||
|
const existing = cards.find((card) => card.id === id);
|
||||||
|
|
||||||
if (existing?.imageUrl) {
|
if (existing?.imageUrl) {
|
||||||
await deleteRemoteImage(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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeCards(cards.filter((card) => card.id !== id));
|
|
||||||
return redirect("/admin");
|
|
||||||
}, {
|
}, {
|
||||||
detail: {
|
detail: {
|
||||||
tags: ["admin"],
|
tags: ["admin"],
|
||||||
|
|||||||
+40
-35
@@ -1,4 +1,4 @@
|
|||||||
import { del, get, put } from "@vercel/blob";
|
import { del, list, put } from "@vercel/blob";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -17,45 +17,54 @@ const localDataDir = path.resolve(".data");
|
|||||||
const localUploadsDir = path.join(localDataDir, "uploads");
|
const localUploadsDir = path.join(localDataDir, "uploads");
|
||||||
const localCardsPath = path.join(localDataDir, "cards.json");
|
const localCardsPath = path.join(localDataDir, "cards.json");
|
||||||
|
|
||||||
export const storageMode = process.env.BLOB_READ_WRITE_TOKEN ? "Vercel Blob" : "Local file";
|
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[]> {
|
export async function readCards(): Promise<BoardCard[]> {
|
||||||
if (process.env.BLOB_READ_WRITE_TOKEN) {
|
if (hasBlobToken()) {
|
||||||
try {
|
const { blobs } = await list({ prefix: dataBlobPath, limit: 1 });
|
||||||
const result = await get(dataBlobPath, { access: "private" });
|
if (blobs.length === 0) return [];
|
||||||
if (!result?.stream) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await streamToText(result.stream);
|
const res = await fetch(blobs[0].url);
|
||||||
const parsed = JSON.parse(text) as BoardCard[];
|
if (!res.ok) throw new Error(`Blob read failed: ${res.status}`);
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
const parsed = (await res.json()) as BoardCard[];
|
||||||
return [];
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await readFile(localCardsPath, "utf8");
|
const text = await readFile(localCardsPath, "utf8");
|
||||||
const parsed = JSON.parse(text) as BoardCard[];
|
const parsed = JSON.parse(text) as BoardCard[];
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
return [];
|
if (err?.code === "ENOENT") return [];
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeCards(cards: BoardCard[]) {
|
export async function writeCards(cards: BoardCard[]) {
|
||||||
const content = JSON.stringify(cards, null, 2);
|
const content = JSON.stringify(cards, null, 2);
|
||||||
|
|
||||||
if (process.env.BLOB_READ_WRITE_TOKEN) {
|
if (hasBlobToken()) {
|
||||||
await put(dataBlobPath, content, {
|
await put(dataBlobPath, content, {
|
||||||
access: "private",
|
access: "public",
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
allowOverwrite: true
|
allowOverwrite: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertWritableLocalStorage();
|
||||||
await mkdir(localDataDir, { recursive: true });
|
await mkdir(localDataDir, { recursive: true });
|
||||||
await writeFile(localCardsPath, content, "utf8");
|
await writeFile(localCardsPath, content, "utf8");
|
||||||
}
|
}
|
||||||
@@ -64,7 +73,7 @@ export async function saveImage(file: File) {
|
|||||||
const safeName = safeFileName(file.name || "image");
|
const safeName = safeFileName(file.name || "image");
|
||||||
const pathname = `pozor/images/${Date.now()}-${safeName}`;
|
const pathname = `pozor/images/${Date.now()}-${safeName}`;
|
||||||
|
|
||||||
if (process.env.BLOB_READ_WRITE_TOKEN) {
|
if (hasBlobToken()) {
|
||||||
const blob = await put(pathname, file, {
|
const blob = await put(pathname, file, {
|
||||||
access: "public",
|
access: "public",
|
||||||
contentType: file.type || "application/octet-stream"
|
contentType: file.type || "application/octet-stream"
|
||||||
@@ -72,6 +81,7 @@ export async function saveImage(file: File) {
|
|||||||
return blob.url;
|
return blob.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertWritableLocalStorage();
|
||||||
await mkdir(localUploadsDir, { recursive: true });
|
await mkdir(localUploadsDir, { recursive: true });
|
||||||
const localName = `${Date.now()}-${safeName}`;
|
const localName = `${Date.now()}-${safeName}`;
|
||||||
const localPath = path.join(localUploadsDir, localName);
|
const localPath = path.join(localUploadsDir, localName);
|
||||||
@@ -80,7 +90,7 @@ export async function saveImage(file: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRemoteImage(imageUrl: string) {
|
export async function deleteRemoteImage(imageUrl: string) {
|
||||||
if (!process.env.BLOB_READ_WRITE_TOKEN || !imageUrl.includes(".blob.vercel-storage.com/")) {
|
if (!hasBlobToken() || !imageUrl.includes(".blob.vercel-storage.com/")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +105,16 @@ export function localUploadPath(filename: string) {
|
|||||||
return path.join(localUploadsDir, safeFileName(filename));
|
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[]) {
|
export function publicCards(cards: BoardCard[]) {
|
||||||
return cards
|
return cards
|
||||||
.filter((card) => card.published)
|
.filter((card) => card.published)
|
||||||
@@ -104,18 +124,3 @@ export function publicCards(cards: BoardCard[]) {
|
|||||||
function safeFileName(name: string) {
|
function safeFileName(name: string) {
|
||||||
return name.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-");
|
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");
|
|
||||||
}
|
|
||||||
|
|||||||
+115
-82
@@ -1,76 +1,12 @@
|
|||||||
---
|
---
|
||||||
import "../styles/global.css";
|
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 = (
|
const apiUrl = (
|
||||||
import.meta.env.API_URL ||
|
import.meta.env.API_URL ||
|
||||||
import.meta.env.PUBLIC_API_URL ||
|
import.meta.env.PUBLIC_API_URL ||
|
||||||
"http://localhost:3001"
|
"http://localhost:3001"
|
||||||
).replace(/\/$/, "");
|
).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`;
|
const adminUrl = `${apiUrl}/admin`;
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -90,31 +26,128 @@ const adminUrl = `${apiUrl}/admin`;
|
|||||||
<p class="summary">тут только самое позорное.</p>
|
<p class="summary">тут только самое позорное.</p>
|
||||||
</div>
|
</div>
|
||||||
<aside class="caseTag" aria-label="Board status">
|
<aside class="caseTag" aria-label="Board status">
|
||||||
<span>{boardStamp}</span>
|
<span id="board-stamp">Loading...</span>
|
||||||
<strong>Elysia API</strong>
|
<strong>Elysia API</strong>
|
||||||
<a class="loginButton" href={adminUrl}>Login</a>
|
<a class="loginButton" href={adminUrl}>Login</a>
|
||||||
<small>admin / cards</small>
|
<small>admin / cards</small>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="board" aria-label="Published cards">
|
<section class="board" aria-label="Published cards" id="board">
|
||||||
{boardItems.length === 0 && <div class="notice">В админке пока нет опубликованных карточек.</div>}
|
<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>
|
</section>
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user