add simple admin service
Build and deploy / deploy (push) Successful in 16s

This commit is contained in:
2026-05-18 02:48:47 +03:00
parent 10a83d39a2
commit 3a3b2bcfc0
84 changed files with 2134 additions and 5 deletions
+1
View File
@@ -1,6 +1,7 @@
.git
.gitea
.webarchive
local-admin
deploy
web/.next
web/node_modules
+9
View File
@@ -14,11 +14,20 @@ Useful commands:
```bash
cd ~/tmp/progcode/web
npm install
ADMIN_PORT=3333 npm run admin:posts
node scripts/buildDatabase.mjs
npm run dev -- --port 3000
npm run build
```
Local posts admin:
- run from `web/` with `npm run admin:posts`;
- open `http://127.0.0.1:3333`;
- it edits posts as Markdown and saves generated `contentHtml` to `web/data/articles.json`;
- uploaded files are stored in `web/public/assets/uploads/`;
- it lives in `local-admin/`, outside the Next.js app, and `.dockerignore` excludes it from Docker builds.
Docker static deploy:
```bash
+3
View File
@@ -0,0 +1,3 @@
import { startServer } from './src/app/startServer.mjs';
startServer();
+126
View File
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Локальная админка постов</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<div>
<p class="eyebrow">Только localhost</p>
<h1>Посты ProgCode.Ru</h1>
</div>
<div class="topbar__actions">
<button class="button" id="duplicate-button" type="button">Дублировать</button>
<button class="button" id="new-button" type="button">Новая статья</button>
<button class="button button--primary" id="save-button" type="button">Сохранить</button>
</div>
</header>
<main class="workspace">
<aside class="sidebar" aria-label="Список статей">
<div class="sidebar__header">
<label for="search-input">Поиск</label>
<input id="search-input" type="search" placeholder="Название, slug, категория" autocomplete="off" />
</div>
<div class="article-list" id="article-list"></div>
</aside>
<section class="editor" aria-label="Редактор статьи">
<div class="status-line">
<span id="status-text">Загрузка...</span>
<a id="open-article-link" href="#" target="_blank" rel="noreferrer">Открыть статью</a>
</div>
<form class="article-form" id="article-form">
<div class="form-grid">
<label class="field field--wide">
<span>Заголовок</span>
<input name="title" type="text" required />
</label>
<label class="field">
<span>Slug</span>
<input name="slug" type="text" required />
</label>
<label class="field">
<span>Дата</span>
<input name="date" type="text" placeholder="2019-01-12T21:54:18+00:00" required />
</label>
<label class="field">
<span>Автор</span>
<input name="author" type="text" required />
</label>
<label class="field">
<span>Минут чтения</span>
<input name="readingMinutes" type="number" min="1" step="1" required />
</label>
<label class="field field--wide">
<span>Категории</span>
<input name="categories" type="text" placeholder="JavaScript, Bitrix" required />
</label>
<label class="field field--wide">
<span>Обложка</span>
<div class="field-row">
<input name="cover" type="text" list="asset-options" required />
<button class="button" id="cover-upload-button" type="button">Загрузить</button>
</div>
<input id="cover-upload-input" type="file" accept="image/*" hidden />
<datalist id="asset-options"></datalist>
</label>
<label class="field field--wide">
<span>Анонс</span>
<textarea name="excerpt" rows="4" required></textarea>
</label>
</div>
<div class="editor-tools">
<button class="button" id="slug-button" type="button">Slug из заголовка</button>
<button class="button" id="minutes-button" type="button">Оценить время</button>
<button class="button button--danger" id="delete-button" type="button">Удалить</button>
</div>
<div class="content-layout">
<section class="field content-field">
<div class="content-field__header">
<span>Markdown статьи</span>
<div class="markdown-toolbar" aria-label="Форматирование Markdown">
<button class="tool-button" data-format="h2" type="button" title="Заголовок H2">H2</button>
<button class="tool-button" data-format="h3" type="button" title="Заголовок H3">H3</button>
<button class="tool-button" data-format="bold" type="button" title="Жирный">B</button>
<button class="tool-button" data-format="italic" type="button" title="Курсив">I</button>
<button class="tool-button" data-format="code" type="button" title="Код">Code</button>
<button class="tool-button" data-format="quote" type="button" title="Цитата">Quote</button>
<button class="tool-button" data-format="list" type="button" title="Список">List</button>
<button class="tool-button" data-format="link" type="button" title="Ссылка">Link</button>
<button class="tool-button" id="asset-upload-button" type="button" title="Загрузить и вставить ассет">
Upload
</button>
</div>
</div>
<textarea name="contentMarkdown" spellcheck="false" required></textarea>
<input id="asset-upload-input" type="file" multiple hidden />
</section>
<section class="preview" aria-label="Предпросмотр">
<div class="preview__cover">
<img id="cover-preview" alt="" />
</div>
<iframe id="content-preview" title="Предпросмотр HTML статьи" sandbox=""></iframe>
</section>
</div>
</form>
</section>
</main>
<script src="/src/app/main.js" type="module"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
import { bindMarkdownToolbar } from '../features/markdown-editor/formatting.js';
import { estimateReadingMinutes } from '../shared/lib/markdown.js';
import { slugify } from '../shared/lib/slug.js';
export function bindEvents(context) {
context.form.addEventListener('input', () => {
context.setDirty(true);
context.renderPreview();
});
context.searchInput.addEventListener('input', context.renderArticles);
context.newButton.addEventListener('click', context.createNewArticle);
context.duplicateButton.addEventListener('click', context.duplicateArticle);
context.saveButton.addEventListener('click', context.saveArticle);
context.deleteButton.addEventListener('click', context.deleteArticle);
context.coverUploadButton.addEventListener('click', () => context.coverUploadInput.click());
context.coverUploadInput.addEventListener('change', context.uploadCover);
context.assetUploadButton.addEventListener('click', () => context.assetUploadInput.click());
context.assetUploadInput.addEventListener('change', context.uploadMarkdownAssets);
bindMarkdownToolbar({
field: context.fields.contentMarkdown,
onChange() {
context.setDirty(true);
context.renderPreview();
},
});
context.slugButton.addEventListener('click', () => {
context.fields.slug.value = slugify(context.fields.title.value);
context.setDirty(true);
});
context.minutesButton.addEventListener('click', () => {
context.fields.readingMinutes.value = estimateReadingMinutes(context.fields.contentMarkdown.value);
context.setDirty(true);
});
}
+15
View File
@@ -0,0 +1,15 @@
import { getArticles } from '../entities/article/api.js';
export async function bootstrap(context) {
context.setStatus('Загрузка данных...');
try {
const [articles] = await Promise.all([getArticles(), context.loadAssets()]);
context.state.articles = articles;
context.renderArticles();
context.selectArticle(context.state.articles[0]?.slug || null);
context.setStatus(`Загружено ${context.state.articles.length} статей`);
} catch (error) {
context.setStatus(error.message);
}
}
@@ -0,0 +1,50 @@
import { getAssets } from '../entities/asset/api.js';
import { deleteArticle } from '../features/article-actions/deleteArticle.js';
import { duplicateArticle } from '../features/article-actions/duplicateArticle.js';
import { createNewArticle } from '../features/article-actions/createNewArticle.js';
import { reloadArticles } from '../features/article-actions/reloadArticles.js';
import { renderArticles } from '../features/article-actions/renderArticles.js';
import { saveArticle } from '../features/article-actions/saveArticle.js';
import { selectArticle } from '../features/article-actions/selectArticle.js';
import { updateArticleButtons } from '../features/article-actions/buttons.js';
import { mergeAssetPaths } from '../features/asset-picker/mergeAssets.js';
import { renderAssetOptions } from '../features/asset-picker/renderAssetOptions.js';
import { uploadCover } from '../features/asset-upload/uploadCover.js';
import { uploadMarkdownAssets } from '../features/asset-upload/uploadMarkdownAssets.js';
import { renderPreview } from '../features/preview-panel/renderPreview.js';
import { confirmDiscard, setDirty } from '../features/session-state/dirtyState.js';
import { setStatus } from '../features/session-state/status.js';
import { createAdminState } from './model/createAdminState.js';
export function createAdminContext(dom) {
const context = {
...dom,
state: createAdminState(),
};
context.confirmDiscard = () => confirmDiscard(context.state);
context.deleteArticle = () => deleteArticle(context);
context.duplicateArticle = () => duplicateArticle(context);
context.createNewArticle = () => createNewArticle(context);
context.loadAssets = async () => {
context.state.assets = await getAssets();
context.renderAssetOptions();
};
context.mergeUploadedAssets = (uploaded) => {
context.state.assets = mergeAssetPaths(context.state.assets, uploaded);
context.renderAssetOptions();
};
context.reloadArticles = (selectedSlug) => reloadArticles(context, selectedSlug);
context.renderArticles = () => renderArticles(context, context.selectArticle);
context.renderAssetOptions = () => renderAssetOptions(context.assetOptions, context.state.assets);
context.renderPreview = () => renderPreview(context);
context.saveArticle = () => saveArticle(context);
context.selectArticle = (slug, options) => selectArticle(slug, context, options);
context.setDirty = (dirty) => setDirty({ dirty, saveButton: context.saveButton, state: context.state });
context.setStatus = (message) => setStatus(context.statusText, message);
context.updateButtons = () => updateArticleButtons(context);
context.uploadCover = () => uploadCover(context);
context.uploadMarkdownAssets = () => uploadMarkdownAssets(context);
return context;
}
+39
View File
@@ -0,0 +1,39 @@
export function getAdminDom() {
const form = document.querySelector('#article-form');
return {
articleList: document.querySelector('#article-list'),
assetOptions: document.querySelector('#asset-options'),
assetUploadButton: document.querySelector('#asset-upload-button'),
assetUploadInput: document.querySelector('#asset-upload-input'),
contentPreview: document.querySelector('#content-preview'),
coverPreview: document.querySelector('#cover-preview'),
coverUploadButton: document.querySelector('#cover-upload-button'),
coverUploadInput: document.querySelector('#cover-upload-input'),
deleteButton: document.querySelector('#delete-button'),
duplicateButton: document.querySelector('#duplicate-button'),
form,
fields: getArticleFields(form),
minutesButton: document.querySelector('#minutes-button'),
newButton: document.querySelector('#new-button'),
openArticleLink: document.querySelector('#open-article-link'),
saveButton: document.querySelector('#save-button'),
searchInput: document.querySelector('#search-input'),
slugButton: document.querySelector('#slug-button'),
statusText: document.querySelector('#status-text'),
};
}
function getArticleFields(form) {
return {
author: form.elements.author,
categories: form.elements.categories,
contentMarkdown: form.elements.contentMarkdown,
cover: form.elements.cover,
date: form.elements.date,
excerpt: form.elements.excerpt,
readingMinutes: form.elements.readingMinutes,
slug: form.elements.slug,
title: form.elements.title,
};
}
+11
View File
@@ -0,0 +1,11 @@
import { protectUnsavedChanges } from '../features/session-state/dirtyState.js';
import { bootstrap } from './bootstrap.js';
import { bindEvents } from './bindEvents.js';
import { createAdminContext } from './createAdminContext.js';
import { getAdminDom } from './dom.js';
const context = createAdminContext(getAdminDom());
bindEvents(context);
protectUnsavedChanges(context.state);
await bootstrap(context);
@@ -0,0 +1,9 @@
export function createAdminState() {
return {
articles: [],
assets: [],
current: null,
dirty: false,
selectedSlug: null,
};
}
@@ -0,0 +1,31 @@
import { fetchJson } from '../../shared/api/http.js';
export async function getArticles() {
const response = await fetchJson('/api/articles');
return response.articles;
}
export async function createArticle(article) {
const response = await fetchJson('/api/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ article }),
});
return response.article;
}
export async function updateArticle(slug, article) {
const response = await fetchJson(`/api/articles/${encodeURIComponent(slug)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ article }),
});
return response.article;
}
export async function deleteArticle(slug) {
const response = await fetchJson(`/api/articles/${encodeURIComponent(slug)}`, { method: 'DELETE' });
return response.article;
}
@@ -0,0 +1,16 @@
import { fetchJson } from '../../shared/api/http.js';
export async function getAssets() {
const response = await fetchJson('/api/assets');
return response.assets;
}
export async function uploadAsset({ dataUrl, fileName }) {
const response = await fetchJson('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataUrl, fileName }),
});
return response.asset;
}
@@ -0,0 +1,4 @@
export function updateArticleButtons({ deleteButton, duplicateButton, state }) {
duplicateButton.disabled = !state.current;
deleteButton.disabled = !state.selectedSlug;
}
@@ -0,0 +1,17 @@
import { createArticleDraft } from '../article-editor/createArticleDraft.js';
import { fillArticleForm } from '../article-editor/form.js';
export function createNewArticle(context) {
if (!context.confirmDiscard()) {
return;
}
context.state.current = createArticleDraft({ articles: context.state.articles, assets: context.state.assets });
context.state.selectedSlug = null;
fillArticleForm(context.fields, context.state.current);
context.setStatus('Новая статья еще не сохранена');
context.setDirty(true);
context.updateButtons();
context.renderPreview();
context.renderArticles();
}
@@ -0,0 +1,29 @@
import { deleteArticle as deleteArticleRequest } from '../../entities/article/api.js';
export async function deleteArticle(context) {
if (!context.state.selectedSlug || !context.state.current) {
return;
}
const confirmed = window.confirm(`Удалить статью "${context.state.current.title}"? Это изменит articles.json.`);
if (!confirmed) {
return;
}
context.setStatus('Удаление...');
context.deleteButton.disabled = true;
try {
await deleteArticleRequest(context.state.selectedSlug);
context.state.current = null;
context.state.selectedSlug = null;
context.setDirty(false);
await context.reloadArticles(null);
context.setStatus('Статья удалена');
} catch (error) {
context.setStatus(error.message);
} finally {
context.deleteButton.disabled = false;
context.updateButtons();
}
}
@@ -0,0 +1,20 @@
import { fillArticleForm } from '../article-editor/form.js';
import { uniqueDraftSlug } from '../../shared/lib/slug.js';
export function duplicateArticle(context) {
if (!context.state.current || !context.confirmDiscard()) {
return;
}
const copy = structuredClone(context.state.current);
copy.slug = uniqueDraftSlug(`${copy.slug}-copy`, context.state.articles);
copy.title = `${copy.title} - копия`;
context.state.current = copy;
context.state.selectedSlug = null;
fillArticleForm(context.fields, copy);
context.setStatus('Дубликат еще не сохранен');
context.setDirty(true);
context.updateButtons();
context.renderPreview();
context.renderArticles();
}
@@ -0,0 +1,13 @@
import { getArticles } from '../../entities/article/api.js';
export async function reloadArticles(context, selectedSlug = context.state.selectedSlug) {
context.state.articles = await getArticles();
context.renderArticles();
if (selectedSlug && context.state.articles.some((article) => article.slug === selectedSlug)) {
context.selectArticle(selectedSlug, { skipConfirm: true });
return;
}
context.selectArticle(context.state.articles[0]?.slug || null, { skipConfirm: true });
}
@@ -0,0 +1,11 @@
import { renderArticleList } from '../../widgets/article-list/ui.js';
export function renderArticles({ articleList, searchInput, state }, onSelect) {
renderArticleList({
articles: state.articles,
container: articleList,
onSelect,
query: searchInput.value,
selectedSlug: state.selectedSlug,
});
}
@@ -0,0 +1,21 @@
import { createArticle, updateArticle } from '../../entities/article/api.js';
import { readArticleForm } from '../article-editor/form.js';
export async function saveArticle(context) {
const article = readArticleForm(context.fields, context.state.current);
const isNew = !context.state.selectedSlug;
context.setStatus('Сохранение...');
context.saveButton.disabled = true;
try {
const savedArticle = isNew ? await createArticle(article) : await updateArticle(context.state.selectedSlug, article);
await context.reloadArticles(savedArticle.slug);
context.setStatus(`Сохранено: ${savedArticle.title}`);
} catch (error) {
context.setStatus(error.message);
} finally {
context.saveButton.disabled = false;
context.updateButtons();
}
}
@@ -0,0 +1,28 @@
import { fillArticleForm } from '../article-editor/form.js';
export function selectArticle(slug, context, options = {}) {
if (!options.skipConfirm && !context.confirmDiscard()) {
return;
}
const article = context.state.articles.find((item) => item.slug === slug) || null;
context.state.current = article ? structuredClone(article) : null;
context.state.selectedSlug = article?.slug || null;
if (!context.state.current) {
context.form.reset();
context.setStatus('Нет статей');
context.setDirty(false);
context.updateButtons();
context.renderPreview();
context.renderArticles();
return;
}
fillArticleForm(context.fields, context.state.current);
context.setStatus(`Редактируется: ${context.state.current.title}`);
context.setDirty(false);
context.updateButtons();
context.renderPreview();
context.renderArticles();
}
@@ -0,0 +1,15 @@
import { markdownToHtml } from '../../shared/lib/markdown.js';
export function createArticleDraft({ articles, assets }) {
return {
slug: `new-post-${Date.now()}`,
title: 'Новая статья',
date: new Date().toISOString(),
author: articles[0]?.author || 'DarkRiDDeR',
categories: articles[0]?.categories || ['Разное'],
cover: assets[0] || '/assets/illustrations/vibe-cover.svg',
excerpt: 'Короткий анонс статьи.',
contentHtml: markdownToHtml('Текст статьи.'),
readingMinutes: 1,
};
}
@@ -0,0 +1,13 @@
import { htmlToMarkdown } from '../../shared/lib/markdown.js';
export function fillArticleForm(fields, article) {
fields.title.value = article.title || '';
fields.slug.value = article.slug || '';
fields.date.value = article.date || '';
fields.author.value = article.author || '';
fields.categories.value = (article.categories || []).join(', ');
fields.cover.value = article.cover || '';
fields.excerpt.value = article.excerpt || '';
fields.contentMarkdown.value = article.contentMarkdown || htmlToMarkdown(article.contentHtml || '');
fields.readingMinutes.value = article.readingMinutes || 1;
}
@@ -0,0 +1,2 @@
export { fillArticleForm } from './fillArticleForm.js';
export { readArticleForm } from './readArticleForm.js';
@@ -0,0 +1,22 @@
import { markdownToHtml } from '../../shared/lib/markdown.js';
export function readArticleForm(fields, currentArticle) {
const article = {
...(currentArticle || {}),
slug: fields.slug.value.trim(),
title: fields.title.value.trim(),
date: fields.date.value.trim(),
author: fields.author.value.trim(),
categories: fields.categories.value
.split(',')
.map((category) => category.trim())
.filter(Boolean),
cover: fields.cover.value.trim(),
excerpt: fields.excerpt.value.trim(),
contentHtml: markdownToHtml(fields.contentMarkdown.value),
readingMinutes: Number(fields.readingMinutes.value),
};
delete article.contentMarkdown;
return article;
}
@@ -0,0 +1,5 @@
export function mergeAssetPaths(currentAssets, uploadedAssets) {
return [...new Set([...uploadedAssets.map((asset) => asset.path), ...currentAssets])].sort((left, right) =>
left.localeCompare(right, 'ru'),
);
}
@@ -0,0 +1,9 @@
export function renderAssetOptions(assetOptions, assets) {
assetOptions.replaceChildren(
...assets.map((asset) => {
const option = document.createElement('option');
option.value = asset;
return option;
}),
);
}
@@ -0,0 +1,4 @@
export function assetToMarkdown(asset, file) {
const label = file.name.replace(/\.[^.]+$/, '') || asset.fileName;
return asset.isImage ? `![${label}](${asset.path})` : `[${label}](${asset.path})`;
}
@@ -0,0 +1,21 @@
import { uploadAsset } from '../../entities/asset/api.js';
import { readFileAsDataUrl } from '../../shared/lib/file.js';
export async function uploadFiles(files, options) {
const uploaded = [];
options.setStatus(`Загрузка файлов: ${files.length}`);
try {
for (const file of files) {
const dataUrl = await readFileAsDataUrl(file);
const asset = await uploadAsset({ dataUrl, fileName: file.name });
uploaded.push(asset);
options.onUploaded(asset, file);
}
options.onComplete(uploaded);
options.setStatus(`${options.statusPrefix}: ${uploaded.map((asset) => asset.path).join(', ')}`);
} catch (error) {
options.setStatus(error.message);
}
}
@@ -0,0 +1,26 @@
import { uploadFiles } from './upload-assets.js';
export async function uploadCover(context) {
const [file] = context.coverUploadInput.files || [];
context.coverUploadInput.value = '';
if (!file) {
return;
}
if (!file.type.startsWith('image/')) {
context.setStatus('Для обложки можно загрузить только изображение');
return;
}
await uploadFiles([file], {
onComplete: context.mergeUploadedAssets,
onUploaded(asset) {
context.fields.cover.value = asset.path;
context.setDirty(true);
context.renderPreview();
},
setStatus: context.setStatus,
statusPrefix: 'Обложка загружена',
});
}
@@ -0,0 +1,28 @@
import { insertAtCursor } from '../markdown-editor/formatting.js';
import { assetToMarkdown } from './assetToMarkdown.js';
import { uploadFiles } from './upload-assets.js';
export async function uploadMarkdownAssets(context) {
const files = Array.from(context.assetUploadInput.files || []);
context.assetUploadInput.value = '';
if (files.length === 0) {
return;
}
await uploadFiles(files, {
onComplete: context.mergeUploadedAssets,
onUploaded(asset, file) {
insertAtCursor({
field: context.fields.contentMarkdown,
onChange() {
context.setDirty(true);
context.renderPreview();
},
text: `${assetToMarkdown(asset, file)}\n`,
});
},
setStatus: context.setStatus,
statusPrefix: 'Ассет загружен',
});
}
@@ -0,0 +1,30 @@
import { prefixSelectionLines } from './prefixSelectionLines.js';
import { wrapSelection } from './wrapSelection.js';
export function applyMarkdownFormat({ field, format, onChange }) {
if (format === 'link') {
applyLinkFormat({ field, onChange });
return;
}
const actions = {
bold: () => wrapSelection({ fallback: 'жирный текст', field, onChange, prefix: '**', suffix: '**' }),
code: () => wrapSelection({ fallback: 'code', field, onChange, prefix: '`', suffix: '`' }),
h2: () => prefixSelectionLines({ field, onChange, prefix: '## ' }),
h3: () => prefixSelectionLines({ field, onChange, prefix: '### ' }),
italic: () => wrapSelection({ fallback: 'курсив', field, onChange, prefix: '*', suffix: '*' }),
list: () => prefixSelectionLines({ field, onChange, prefix: '- ' }),
quote: () => prefixSelectionLines({ field, onChange, prefix: '> ' }),
};
actions[format]?.();
}
function applyLinkFormat({ field, onChange }) {
const url = window.prompt('URL ссылки');
if (!url) {
return;
}
wrapSelection({ fallback: 'текст ссылки', field, onChange, prefix: '[', suffix: `](${url})` });
}
@@ -0,0 +1,7 @@
import { applyMarkdownFormat } from './applyMarkdownFormat.js';
export function bindMarkdownToolbar({ field, onChange, root = document }) {
root.querySelectorAll('[data-format]').forEach((button) => {
button.addEventListener('click', () => applyMarkdownFormat({ field, format: button.dataset.format, onChange }));
});
}
@@ -0,0 +1,2 @@
export { bindMarkdownToolbar } from './bindMarkdownToolbar.js';
export { insertAtCursor } from './insertAtCursor.js';
@@ -0,0 +1,14 @@
export function insertAtCursor({ field, onChange, text }) {
const start = field.selectionStart;
const end = field.selectionEnd;
const before = field.value.slice(0, start);
const after = field.value.slice(end);
const separatorBefore = before && !before.endsWith('\n') ? '\n' : '';
const separatorAfter = after && !after.startsWith('\n') ? '\n' : '';
field.value = `${before}${separatorBefore}${text}${separatorAfter}${after}`;
const cursor = before.length + separatorBefore.length + text.length;
field.focus();
field.setSelectionRange(cursor, cursor);
onChange();
}
@@ -0,0 +1,15 @@
export function prefixSelectionLines({ field, onChange, prefix }) {
const start = field.selectionStart;
const end = field.selectionEnd;
const lineStart = field.value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
const selected = field.value.slice(lineStart, end) || 'текст';
const replacement = selected
.split('\n')
.map((line) => (line.startsWith(prefix) ? line : `${prefix}${line}`))
.join('\n');
field.value = `${field.value.slice(0, lineStart)}${replacement}${field.value.slice(end)}`;
field.focus();
field.setSelectionRange(lineStart, lineStart + replacement.length);
onChange();
}
@@ -0,0 +1,11 @@
export function wrapSelection({ fallback, field, onChange, prefix, suffix }) {
const start = field.selectionStart;
const end = field.selectionEnd;
const selected = field.value.slice(start, end) || fallback;
const nextValue = `${field.value.slice(0, start)}${prefix}${selected}${suffix}${field.value.slice(end)}`;
field.value = nextValue;
field.focus();
field.setSelectionRange(start + prefix.length, start + prefix.length + selected.length);
onChange();
}
@@ -0,0 +1,19 @@
import { markdownToHtml, previewStyles } from '../../shared/lib/markdown.js';
export function renderPreview({ contentPreview, coverPreview, fields, openArticleLink }) {
const cover = fields.cover.value.trim();
coverPreview.src = cover || '';
coverPreview.hidden = !cover;
contentPreview.srcdoc = createPreviewDocument(fields.contentMarkdown.value);
updateArticleLink(openArticleLink, fields.slug.value.trim());
}
function createPreviewDocument(markdown) {
return `<!doctype html><html lang="ru"><head><meta charset="utf-8"><style>${previewStyles}</style></head><body>${markdownToHtml(markdown)}</body></html>`;
}
function updateArticleLink(openArticleLink, slug) {
openArticleLink.href = slug ? `http://localhost:3000/articles/${encodeURIComponent(slug)}/` : '#';
openArticleLink.hidden = !slug;
}
@@ -0,0 +1,20 @@
export function setDirty({ dirty, saveButton, state }) {
state.dirty = dirty;
document.title = `${dirty ? '* ' : ''}Локальная админка постов`;
saveButton.textContent = dirty ? 'Сохранить *' : 'Сохранить';
}
export function confirmDiscard(state) {
return !state.dirty || window.confirm('Есть несохраненные изменения. Сбросить их?');
}
export function protectUnsavedChanges(state) {
window.addEventListener('beforeunload', (event) => {
if (!state.dirty) {
return;
}
event.preventDefault();
event.returnValue = '';
});
}
@@ -0,0 +1,3 @@
export function setStatus(statusText, message) {
statusText.textContent = message;
}
+10
View File
@@ -0,0 +1,10 @@
export async function fetchJson(url, options) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
+13
View File
@@ -0,0 +1,13 @@
export function formatDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(date);
}
@@ -0,0 +1,8 @@
export function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', () => reject(reader.error));
reader.readAsDataURL(file);
});
}
@@ -0,0 +1,4 @@
export { estimateReadingMinutes } from './markdown/estimateReadingMinutes.js';
export { htmlToMarkdown } from './markdown/htmlToMarkdown.js';
export { markdownToHtml } from './markdown/markdownToHtml.js';
export { previewStyles } from './markdown/previewStyles.js';
@@ -0,0 +1,12 @@
export function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export function escapeAttribute(value) {
return escapeHtml(value).replace(/`/g, '&#096;');
}
@@ -0,0 +1,10 @@
export function estimateReadingMinutes(markdown) {
const text = markdown
.replace(/```[\s\S]*?```/g, ' ')
.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
.replace(/\[[^\]]+\]\([^)]+\)/g, ' ')
.replace(/[#>*_`-]/g, ' ');
const words = text.match(/[a-zа-я0-9]+/giu) || [];
return Math.max(1, Math.ceil(words.length / 180));
}
@@ -0,0 +1,9 @@
import { nodeToMarkdown } from './nodeToMarkdown.js';
import { normalizeMarkdown } from './normalizeMarkdown.js';
export function htmlToMarkdown(html) {
const template = document.createElement('template');
template.innerHTML = html;
return normalizeMarkdown(Array.from(template.content.childNodes).map(nodeToMarkdown).join(''));
}
@@ -0,0 +1,33 @@
import { escapeAttribute, escapeHtml } from './escapeHtml.js';
import { normalizeAssetUrl } from './normalizeAssetUrl.js';
export function inlineToHtml(value) {
const codeTokens = [];
let html = escapeHtml(value).replace(/`([^`]+)`/g, (_match, code) => {
const token = `@@CODE${codeTokens.length}@@`;
codeTokens.push(`<code>${code}</code>`);
return token;
});
html = html
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => renderImage(match, alt, url))
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, text, url) => renderLink(match, text, url))
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
codeTokens.forEach((code, index) => {
html = html.replace(`@@CODE${index}@@`, code);
});
return html;
}
function renderImage(match, alt, url) {
const safeUrl = normalizeAssetUrl(url);
return safeUrl ? `<img src="${escapeAttribute(safeUrl)}" alt="${escapeAttribute(alt)}" />` : escapeHtml(match);
}
function renderLink(match, text, url) {
const safeUrl = normalizeAssetUrl(url);
return safeUrl ? `<a href="${escapeAttribute(safeUrl)}">${text}</a>` : escapeHtml(match);
}
@@ -0,0 +1,125 @@
import { escapeHtml } from './escapeHtml.js';
import { inlineToHtml } from './inlineToHtml.js';
export function markdownToHtml(markdown) {
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
const html = [];
const paragraph = [];
let listType = null;
let codeLines = null;
const flushParagraph = () => {
if (paragraph.length === 0) {
return;
}
html.push(`<p>${inlineToHtml(paragraph.join(' '))}</p>`);
paragraph.length = 0;
};
const closeList = () => {
if (!listType) {
return;
}
html.push(`</${listType}>`);
listType = null;
};
for (const line of lines) {
if (/^```/.test(line)) {
codeLines = toggleCodeBlock({ codeLines, flushParagraph, closeList, html });
continue;
}
if (codeLines) {
codeLines.push(line);
continue;
}
if (!line.trim()) {
flushParagraph();
closeList();
continue;
}
if (renderHeading({ closeList, flushParagraph, html, line })) {
continue;
}
if (renderQuote({ closeList, flushParagraph, html, line })) {
continue;
}
const renderedListType = renderListItem({ closeList, flushParagraph, html, line, listType });
if (renderedListType) {
listType = renderedListType;
continue;
}
paragraph.push(line.trim());
}
if (codeLines) {
html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
}
flushParagraph();
closeList();
return html.join('\n');
}
function toggleCodeBlock({ closeList, codeLines, flushParagraph, html }) {
if (codeLines) {
html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
return null;
}
flushParagraph();
closeList();
return [];
}
function renderHeading({ closeList, flushParagraph, html, line }) {
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (!heading) {
return false;
}
flushParagraph();
closeList();
html.push(`<h${heading[1].length}>${inlineToHtml(heading[2])}</h${heading[1].length}>`);
return true;
}
function renderQuote({ closeList, flushParagraph, html, line }) {
const quote = line.match(/^>\s?(.+)$/);
if (!quote) {
return false;
}
flushParagraph();
closeList();
html.push(`<blockquote>${inlineToHtml(quote[1])}</blockquote>`);
return true;
}
function renderListItem({ closeList, flushParagraph, html, line, listType }) {
const unordered = line.match(/^[-*]\s+(.+)$/);
const ordered = line.match(/^\d+\.\s+(.+)$/);
if (!unordered && !ordered) {
return null;
}
flushParagraph();
const nextListType = unordered ? 'ul' : 'ol';
if (listType !== nextListType) {
closeList();
html.push(`<${nextListType}>`);
}
html.push(`<li>${inlineToHtml((unordered || ordered)[1])}</li>`);
return nextListType;
}
@@ -0,0 +1,73 @@
export function nodeToMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent.replace(/\s+/g, ' ');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
return elementToMarkdown(node);
}
function elementToMarkdown(node) {
const tag = node.tagName.toLowerCase();
const children = () => Array.from(node.childNodes).map(nodeToMarkdown).join('').trim();
const renderers = {
a: () => renderLink(node, children),
b: () => `**${children()}**`,
blockquote: () => renderBlockquote(children()),
br: () => '\n',
code: () => renderCode(node),
em: () => `*${children()}*`,
figcaption: () => `\n*${children()}*`,
figure: () => `\n\n${children()}\n\n`,
i: () => `*${children()}*`,
img: () => renderImage(node),
li: () => `- ${children()}\n`,
ol: () => renderList(node),
p: () => `\n\n${children()}\n\n`,
pre: () => `\n\n\`\`\`\n${node.textContent.trim()}\n\`\`\`\n\n`,
strong: () => `**${children()}**`,
ul: () => renderList(node),
};
if (/^h[1-6]$/.test(tag)) {
return renderHeading(tag, children());
}
return renderers[tag]?.() || children();
}
function renderBlockquote(content) {
return `\n\n${content
.split('\n')
.map((line) => (line.trim() ? `> ${line.trim()}` : '>'))
.join('\n')}\n\n`;
}
function renderCode(node) {
return node.parentElement?.tagName.toLowerCase() === 'pre' ? node.textContent : `\`${node.textContent.trim()}\``;
}
function renderHeading(tag, content) {
const level = Math.min(Number(tag.slice(1)), 3);
return `\n\n${'#'.repeat(level)} ${content}\n\n`;
}
function renderImage(node) {
const alt = node.getAttribute('alt') || '';
const src = node.getAttribute('src') || '';
return src ? `![${alt}](${src})` : '';
}
function renderLink(node, children) {
const text = children() || node.getAttribute('href') || '';
const href = node.getAttribute('href') || '';
return href ? `[${text}](${href})` : text;
}
function renderList(node) {
return `\n${Array.from(node.children).map(nodeToMarkdown).join('')}\n`;
}
@@ -0,0 +1,9 @@
export function normalizeAssetUrl(url) {
const normalized = url.trim();
if (/^(https?:|\/|#)/i.test(normalized)) {
return normalized;
}
return null;
}
@@ -0,0 +1,6 @@
export function normalizeMarkdown(markdown) {
return markdown
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
@@ -0,0 +1,13 @@
export const previewStyles = `
body { margin: 0; padding: 24px; color: #1e252d; font: 18px/1.65 Arial, Helvetica, sans-serif; }
h1 { margin: 38px 0 14px; font-size: 34px; line-height: 1.15; }
h2 { margin: 36px 0 12px; font-size: 30px; line-height: 1.2; }
h3 { margin: 30px 0 10px; font-size: 24px; line-height: 1.2; }
p, ul, ol, blockquote { margin: 0 0 20px; }
blockquote { border-left: 3px solid #cfd7df; padding-left: 16px; color: #617080; }
a { color: #176b5d; }
img { max-width: 100%; border: 1px solid #d8d0c2; border-radius: 8px; }
pre { overflow-x: auto; border-radius: 8px; padding: 18px 20px; background: #111827; color: #e5e7eb; font-size: 15px; }
code { font-family: SFMono-Regular, Menlo, Consolas, monospace; }
:not(pre) > code { border-radius: 4px; padding: 2px 5px; background: #eef1f4; }
`;
+24
View File
@@ -0,0 +1,24 @@
export function slugify(value) {
return (
value
.toLowerCase()
.trim()
.replace(/ё/g, 'е')
.replace(/[^a-zа-я0-9]+/giu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 90) || `post-${Date.now()}`
);
}
export function uniqueDraftSlug(slug, articles) {
const used = new Set(articles.map((article) => article.slug));
let candidate = slug;
let index = 2;
while (used.has(candidate)) {
candidate = `${slug}-${index}`;
index += 1;
}
return candidate;
}
@@ -0,0 +1,39 @@
import { formatDate } from '../../shared/lib/date.js';
export function renderArticleList({ articles, container, onSelect, query, selectedSlug }) {
const normalizedQuery = query.trim().toLowerCase();
const visibleArticles = articles.filter((article) => {
if (!normalizedQuery) {
return true;
}
return [article.title, article.slug, article.author, ...(article.categories || [])]
.join(' ')
.toLowerCase()
.includes(normalizedQuery);
});
if (visibleArticles.length === 0) {
container.innerHTML = '<div class="article-list__empty">Статей не найдено</div>';
return;
}
container.replaceChildren(
...visibleArticles.map((article) => {
const button = document.createElement('button');
button.className = 'article-item';
button.type = 'button';
button.setAttribute('aria-selected', String(article.slug === selectedSlug));
button.addEventListener('click', () => onSelect(article.slug));
const title = document.createElement('strong');
title.textContent = article.title;
const meta = document.createElement('span');
meta.textContent = `${formatDate(article.date)} · ${article.slug}`;
button.append(title, meta);
return button;
}),
);
}
+386
View File
@@ -0,0 +1,386 @@
:root {
--bg: #eef1f4;
--panel: #ffffff;
--ink: #1f2933;
--muted: #617080;
--line: #cfd7df;
--accent: #176b5d;
--accent-strong: #0d5549;
--danger: #b42318;
--danger-bg: #fff1f0;
--shadow: 0 18px 45px rgba(31, 41, 51, 0.1);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: Arial, Helvetica, sans-serif;
line-height: 1.5;
}
button,
input,
textarea {
font: inherit;
}
.topbar {
min-height: 92px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 18px 28px;
border-bottom: 1px solid var(--line);
background: #f9fafb;
}
.topbar h1 {
margin: 2px 0 0;
font-size: 28px;
line-height: 1.15;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.topbar__actions,
.editor-tools {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.button {
min-height: 38px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 13px;
background: #ffffff;
color: var(--ink);
cursor: pointer;
}
.button:hover {
border-color: #9aa8b6;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.button--primary {
border-color: var(--accent-strong);
background: var(--accent);
color: #ffffff;
}
.button--danger {
border-color: #f0b8b2;
background: var(--danger-bg);
color: var(--danger);
}
.workspace {
height: calc(100vh - 92px);
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
}
.sidebar {
min-height: 0;
border-right: 1px solid var(--line);
background: #f8fafc;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sidebar__header {
padding: 18px;
border-bottom: 1px solid var(--line);
}
.sidebar__header label,
.field span {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: #ffffff;
color: var(--ink);
outline: none;
}
input {
min-height: 38px;
padding: 8px 10px;
}
textarea {
resize: vertical;
padding: 10px;
}
.field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
input:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(23, 107, 93, 0.14);
}
.article-list {
min-height: 0;
overflow: auto;
padding: 10px;
}
.article-list__empty {
padding: 18px 8px;
color: var(--muted);
}
.article-item {
width: 100%;
display: block;
border: 1px solid transparent;
border-radius: 6px;
padding: 10px;
background: transparent;
text-align: left;
}
.article-item:hover {
background: #eef4f6;
}
.article-item[aria-selected='true'] {
border-color: rgba(23, 107, 93, 0.38);
background: #e5f3f0;
}
.article-item strong {
display: block;
line-height: 1.25;
}
.article-item span {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 13px;
}
.editor {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 22px 28px 34px;
}
.status-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
color: var(--muted);
font-size: 14px;
}
.status-line a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.article-form {
display: grid;
gap: 18px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 18px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.field--wide {
grid-column: 1 / -1;
}
.editor-tools {
justify-content: flex-start;
}
.content-layout {
min-height: 680px;
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr);
gap: 18px;
}
.content-field,
.preview {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.content-field {
padding: 14px;
}
.content-field__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.content-field__header > span {
margin-bottom: 0;
}
.markdown-toolbar {
display: flex;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.tool-button {
min-height: 30px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 5px 8px;
background: #f8fafc;
color: var(--ink);
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.tool-button:hover {
border-color: #9aa8b6;
background: #ffffff;
}
.content-field textarea {
flex: 1;
min-height: 560px;
font-family: SFMono-Regular, Menlo, Consolas, monospace;
font-size: 14px;
line-height: 1.55;
}
.preview {
overflow: hidden;
}
.preview__cover {
min-height: 190px;
border-bottom: 1px solid var(--line);
background: #f8fafc;
}
.preview__cover img {
width: 100%;
height: 260px;
object-fit: cover;
}
.preview iframe {
width: 100%;
flex: 1;
min-height: 420px;
border: 0;
background: #ffffff;
}
@media (max-width: 980px) {
.topbar {
align-items: flex-start;
flex-direction: column;
}
.workspace {
height: auto;
grid-template-columns: 1fr;
}
.sidebar {
max-height: 360px;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.content-layout,
.form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.topbar,
.editor {
padding-left: 16px;
padding-right: 16px;
}
.topbar__actions,
.editor-tools {
width: 100%;
}
.button {
flex: 1 1 auto;
}
.status-line {
align-items: flex-start;
flex-direction: column;
}
}
+71
View File
@@ -0,0 +1,71 @@
import { paths } from '../config/paths.mjs';
import {
handleCreateArticle,
handleDeleteArticle,
handleListArticles,
handleUpdateArticle,
} from '../entities/article/articleHandlers.mjs';
import { handleListAssets, handleUploadAsset } from '../entities/asset/assetHandlers.mjs';
import { httpError } from '../shared/http/httpError.mjs';
import { sendError } from '../shared/http/sendError.mjs';
import { serveFile } from '../shared/static/serveFile.mjs';
export async function handleRequest(request, response) {
try {
return await routeRequest(request, response);
} catch (error) {
return sendError(response, error);
}
}
async function routeRequest(request, response) {
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
if (url.pathname === '/api/articles' && request.method === 'GET') {
return await handleListArticles(request, response);
}
if (url.pathname === '/api/articles' && request.method === 'POST') {
return await handleCreateArticle(request, response);
}
if (url.pathname === '/api/assets' && request.method === 'GET') {
return await handleListAssets(request, response);
}
if (url.pathname === '/api/assets' && request.method === 'POST') {
return await handleUploadAsset(request, response);
}
const articleRoute = url.pathname.match(/^\/api\/articles\/(.+)$/);
if (articleRoute) {
return await routeArticleBySlug(request, response, decodeURIComponent(articleRoute[1]));
}
return await routeStaticFile(request, response, url.pathname);
}
async function routeArticleBySlug(request, response, slug) {
if (request.method === 'PUT') {
return await handleUpdateArticle(request, response, slug);
}
if (request.method === 'DELETE') {
return await handleDeleteArticle(request, response, slug);
}
throw httpError(405, 'Method not allowed');
}
async function routeStaticFile(request, response, pathname) {
if (request.method !== 'GET' && request.method !== 'HEAD') {
throw httpError(405, 'Method not allowed');
}
if (pathname.startsWith('/assets/')) {
return await serveFile(response, paths.webPublicRoot, pathname, request.method);
}
const adminPath = pathname === '/' ? '/index.html' : pathname;
return await serveFile(response, paths.adminPublicRoot, adminPath, request.method);
}
+30
View File
@@ -0,0 +1,30 @@
import { createServer } from 'node:http';
import { paths } from '../config/paths.mjs';
import { serverConfig } from '../config/server.mjs';
import { handleRequest } from './requestHandler.mjs';
export function startServer() {
const server = createServer(handleRequest);
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
console.error(`Port ${serverConfig.port} is already in use. Run with ADMIN_PORT=3334 npm run admin:posts.`);
process.exit(1);
}
throw error;
});
server.listen(serverConfig.port, serverConfig.host, () => {
console.log(`Local posts admin: http://${serverConfig.host}:${serverConfig.port}`);
console.log(`Editing: ${paths.articlesFile}`);
});
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
server.close(() => process.exit(0));
});
}
return server;
}
+4
View File
@@ -0,0 +1,4 @@
export const requestLimits = {
maxBodySize: 30 * 1024 * 1024,
maxUploadSize: 20 * 1024 * 1024,
};
+13
View File
@@ -0,0 +1,13 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const repoRoot = resolve(adminRoot, '..');
const webRoot = resolve(repoRoot, 'web');
export const paths = {
adminPublicRoot: resolve(adminRoot, 'public'),
articlesFile: resolve(webRoot, 'data', 'articles.json'),
uploadRoot: resolve(webRoot, 'public', 'assets', 'uploads'),
webPublicRoot: resolve(webRoot, 'public'),
};
+4
View File
@@ -0,0 +1,4 @@
export const serverConfig = {
host: process.env.ADMIN_HOST || '127.0.0.1',
port: Number(process.env.ADMIN_PORT || 3333),
};
@@ -0,0 +1,4 @@
export { handleCreateArticle } from './handleCreateArticle.mjs';
export { handleDeleteArticle } from './handleDeleteArticle.mjs';
export { handleListArticles } from './handleListArticles.mjs';
export { handleUpdateArticle } from './handleUpdateArticle.mjs';
@@ -0,0 +1,20 @@
import { readFile, rename, writeFile } from 'node:fs/promises';
import { paths } from '../../config/paths.mjs';
import { httpError } from '../../shared/http/httpError.mjs';
export async function readArticles() {
const raw = await readFile(paths.articlesFile, 'utf8');
const articles = JSON.parse(raw);
if (!Array.isArray(articles)) {
throw httpError(500, 'Article database must be a JSON array');
}
return articles;
}
export async function writeArticles(articles) {
const temporaryFile = `${paths.articlesFile}.${process.pid}.tmp`;
await writeFile(temporaryFile, `${JSON.stringify(articles, null, 2)}\n`, 'utf8');
await rename(temporaryFile, paths.articlesFile);
}
@@ -0,0 +1,78 @@
import { httpError } from '../../shared/http/httpError.mjs';
export function normalizeArticle(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw httpError(400, 'Article payload must be an object');
}
const article = {
...input,
slug: readRequiredString(input, 'slug'),
title: readRequiredString(input, 'title'),
date: readRequiredString(input, 'date'),
author: readRequiredString(input, 'author'),
categories: normalizeCategories(input.categories),
cover: readRequiredString(input, 'cover'),
excerpt: readRequiredString(input, 'excerpt'),
contentHtml: readRequiredString(input, 'contentHtml', { preserveWhitespace: true }),
readingMinutes: normalizeReadingMinutes(input.readingMinutes),
};
if (/[/?#]/.test(article.slug)) {
throw httpError(400, 'Slug must not contain "/", "?" or "#"');
}
if (Number.isNaN(new Date(article.date).getTime())) {
throw httpError(400, 'Date must be a valid date string');
}
return article;
}
export function assertUniqueSlug(articles, slug, exceptSlug) {
const duplicate = articles.find((article) => article.slug === slug && article.slug !== exceptSlug);
if (duplicate) {
throw httpError(409, `Slug "${slug}" is already used`);
}
}
function readRequiredString(input, key, options = {}) {
const value = input[key];
if (typeof value !== 'string') {
throw httpError(400, `"${key}" must be a string`);
}
const normalized = options.preserveWhitespace ? value : value.trim();
if (!normalized) {
throw httpError(400, `"${key}" is required`);
}
return normalized;
}
function normalizeCategories(value) {
const categories = Array.isArray(value)
? value
: String(value || '')
.split(',')
.map((category) => category.trim());
const normalized = categories.filter(Boolean);
if (normalized.length === 0) {
throw httpError(400, 'At least one category is required');
}
return [...new Set(normalized)];
}
function normalizeReadingMinutes(value) {
const minutes = Number(value);
if (!Number.isInteger(minutes) || minutes < 1) {
throw httpError(400, '"readingMinutes" must be a positive integer');
}
return minutes;
}
@@ -0,0 +1,16 @@
import { readJsonBody } from '../../shared/http/readJsonBody.mjs';
import { sendJson } from '../../shared/http/sendJson.mjs';
import { readArticles, writeArticles } from './articleRepository.mjs';
import { assertUniqueSlug, normalizeArticle } from './articleValidator.mjs';
export async function handleCreateArticle(request, response) {
const payload = await readJsonBody(request);
const article = normalizeArticle(payload.article || payload);
const articles = await readArticles();
assertUniqueSlug(articles, article.slug);
articles.unshift(article);
await writeArticles(articles);
return sendJson(response, { article }, 201);
}
@@ -0,0 +1,17 @@
import { httpError } from '../../shared/http/httpError.mjs';
import { sendJson } from '../../shared/http/sendJson.mjs';
import { readArticles, writeArticles } from './articleRepository.mjs';
export async function handleDeleteArticle(_request, response, slug) {
const articles = await readArticles();
const index = articles.findIndex((article) => article.slug === slug);
if (index === -1) {
throw httpError(404, `Article "${slug}" was not found`);
}
const [article] = articles.splice(index, 1);
await writeArticles(articles);
return sendJson(response, { article });
}
@@ -0,0 +1,6 @@
import { sendJson } from '../../shared/http/sendJson.mjs';
import { readArticles } from './articleRepository.mjs';
export async function handleListArticles(_request, response) {
return sendJson(response, { articles: await readArticles() });
}
@@ -0,0 +1,22 @@
import { httpError } from '../../shared/http/httpError.mjs';
import { readJsonBody } from '../../shared/http/readJsonBody.mjs';
import { sendJson } from '../../shared/http/sendJson.mjs';
import { readArticles, writeArticles } from './articleRepository.mjs';
import { assertUniqueSlug, normalizeArticle } from './articleValidator.mjs';
export async function handleUpdateArticle(request, response, currentSlug) {
const payload = await readJsonBody(request);
const articles = await readArticles();
const index = articles.findIndex((article) => article.slug === currentSlug);
if (index === -1) {
throw httpError(404, `Article "${currentSlug}" was not found`);
}
const article = normalizeArticle(payload.article || payload);
assertUniqueSlug(articles, article.slug, currentSlug);
articles[index] = article;
await writeArticles(articles);
return sendJson(response, { article });
}
@@ -0,0 +1,2 @@
export { handleListAssets } from './handleListAssets.mjs';
export { handleUploadAsset } from './handleUploadAsset.mjs';
@@ -0,0 +1,20 @@
export const imageExtensions = new Set(['.avif', '.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']);
export const assetExtensions = new Set([
...imageExtensions,
'.css',
'.csv',
'.doc',
'.docx',
'.json',
'.md',
'.mp3',
'.mp4',
'.ogg',
'.pdf',
'.txt',
'.webm',
'.xls',
'.xlsx',
'.zip',
]);
@@ -0,0 +1,24 @@
import { stat } from 'node:fs/promises';
import { extname, resolve } from 'node:path';
import { httpError } from '../../shared/http/httpError.mjs';
export async function getUniqueFileName(root, fileName) {
const extension = extname(fileName);
const baseName = extension ? fileName.slice(0, -extension.length) : fileName;
for (let index = 0; index < 1000; index += 1) {
const candidate = index === 0 ? fileName : `${baseName}-${index + 1}${extension}`;
try {
await stat(resolve(root, candidate));
} catch (error) {
if (error.code === 'ENOENT') {
return candidate;
}
throw error;
}
}
throw httpError(500, 'Could not pick a unique file name');
}
@@ -0,0 +1,8 @@
import { resolve } from 'node:path';
import { paths } from '../../config/paths.mjs';
import { sendJson } from '../../shared/http/sendJson.mjs';
import { listAssets } from './listAssets.mjs';
export async function handleListAssets(_request, response) {
return sendJson(response, { assets: await listAssets(resolve(paths.webPublicRoot, 'assets'), '/assets') });
}
@@ -0,0 +1,62 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, resolve } from 'node:path';
import { requestLimits } from '../../config/limits.mjs';
import { paths } from '../../config/paths.mjs';
import { httpError } from '../../shared/http/httpError.mjs';
import { readJsonBody } from '../../shared/http/readJsonBody.mjs';
import { sendJson } from '../../shared/http/sendJson.mjs';
import { assetExtensions, imageExtensions } from './assetTypes.mjs';
import { getUniqueFileName } from './getUniqueFileName.mjs';
import { parseUploadPayload } from './parseUploadPayload.mjs';
import { sanitizeFileName } from './sanitizeFileName.mjs';
export async function handleUploadAsset(request, response) {
const payload = await readJsonBody(request);
const fileName = sanitizeFileName(readRequiredString(payload, 'fileName'));
const extension = extname(fileName).toLowerCase();
validateExtension(extension);
const fileBuffer = parseUploadPayload(payload);
validateUploadSize(fileBuffer.length);
await mkdir(paths.uploadRoot, { recursive: true });
const uniqueFileName = await getUniqueFileName(paths.uploadRoot, fileName);
await writeFile(resolve(paths.uploadRoot, uniqueFileName), fileBuffer);
return sendJson(response, { asset: createAssetResponse(uniqueFileName, extension, fileBuffer.length) }, 201);
}
function createAssetResponse(fileName, extension, size) {
return {
fileName,
isImage: imageExtensions.has(extension),
path: `/assets/uploads/${fileName}`,
size,
};
}
function readRequiredString(input, key) {
const value = input[key];
if (typeof value !== 'string' || !value.trim()) {
throw httpError(400, `"${key}" is required`);
}
return value.trim();
}
function validateExtension(extension) {
if (!assetExtensions.has(extension)) {
throw httpError(400, `Files with "${extension || 'no'}" extension are not allowed`);
}
}
function validateUploadSize(size) {
if (size === 0) {
throw httpError(400, 'Uploaded file is empty');
}
if (size > requestLimits.maxUploadSize) {
throw httpError(413, `Uploaded file must be ${Math.round(requestLimits.maxUploadSize / 1024 / 1024)} MB or smaller`);
}
}
@@ -0,0 +1,24 @@
import { readdir } from 'node:fs/promises';
import { extname, resolve } from 'node:path';
import { imageExtensions } from './assetTypes.mjs';
export async function listAssets(root, publicPrefix) {
const entries = await readdir(root, { withFileTypes: true });
const assets = [];
for (const entry of entries) {
const filePath = resolve(root, entry.name);
const assetPath = `${publicPrefix}/${entry.name}`;
if (entry.isDirectory()) {
assets.push(...(await listAssets(filePath, assetPath)));
continue;
}
if (entry.isFile() && imageExtensions.has(extname(entry.name).toLowerCase())) {
assets.push(assetPath);
}
}
return assets.sort((left, right) => left.localeCompare(right, 'ru'));
}
@@ -0,0 +1,19 @@
import { httpError } from '../../shared/http/httpError.mjs';
export function parseUploadPayload(payload) {
if (typeof payload.dataUrl === 'string') {
const match = payload.dataUrl.match(/^data:[^;]+;base64,(.+)$/);
if (!match) {
throw httpError(400, '"dataUrl" must be a base64 data URL');
}
return Buffer.from(match[1], 'base64');
}
if (typeof payload.contentBase64 === 'string') {
return Buffer.from(payload.contentBase64, 'base64');
}
throw httpError(400, 'Upload payload must include "dataUrl" or "contentBase64"');
}
@@ -0,0 +1,17 @@
import { extname } from 'node:path';
export function sanitizeFileName(fileName) {
const extension = extname(fileName).toLowerCase();
const rawBaseName = fileName.slice(0, extension ? -extension.length : undefined);
const baseName =
rawBaseName
.normalize('NFKD')
.trim()
.replace(/^\.+/, '')
.replace(/[^\p{L}\p{N}._-]+/gu, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 90) || 'asset';
return `${baseName}${extension}`;
}
@@ -0,0 +1,5 @@
export function httpError(status, message) {
const error = new Error(message);
error.status = status;
return error;
}
@@ -0,0 +1,27 @@
import { requestLimits } from '../../config/limits.mjs';
import { httpError } from './httpError.mjs';
export async function readJsonBody(request) {
const chunks = [];
let bodySize = 0;
for await (const chunk of request) {
bodySize += chunk.length;
if (bodySize > requestLimits.maxBodySize) {
throw httpError(413, 'Request body is too large');
}
chunks.push(chunk);
}
if (chunks.length === 0) {
throw httpError(400, 'Request body is empty');
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw httpError(400, 'Request body must be valid JSON');
}
}
+12
View File
@@ -0,0 +1,12 @@
import { sendJson } from './sendJson.mjs';
export function sendError(response, error) {
const status = error.status || 500;
const message = status >= 500 ? 'Internal server error' : error.message;
if (status >= 500) {
console.error(error);
}
return sendJson(response, { error: message }, status);
}
+10
View File
@@ -0,0 +1,10 @@
export function sendJson(response, body, status = 200) {
const payload = `${JSON.stringify(body)}\n`;
response.writeHead(status, {
'Content-Length': Buffer.byteLength(payload),
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
response.end(payload);
}
@@ -0,0 +1,25 @@
export const mimeTypes = {
'.csv': 'text/csv; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.ogg': 'audio/ogg',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.webm': 'video/webm',
'.webp': 'image/webp',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.zip': 'application/zip',
};
@@ -0,0 +1,13 @@
import { resolve, sep } from 'node:path';
export function resolveInside(root, pathname) {
let decodedPath;
try {
decodedPath = decodeURIComponent(pathname);
} catch {
return null;
}
const filePath = resolve(root, decodedPath.replace(/^\/+/, ''));
return filePath === root || filePath.startsWith(`${root}${sep}`) ? filePath : null;
}
@@ -0,0 +1,44 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { extname } from 'node:path';
import { httpError } from '../http/httpError.mjs';
import { mimeTypes } from './mimeTypes.mjs';
import { resolveInside } from './resolveInside.mjs';
export async function serveFile(response, root, pathname, method) {
const filePath = resolveInside(root, pathname);
if (!filePath) {
throw httpError(403, 'Forbidden');
}
const fileStat = await getFileStat(filePath);
response.writeHead(200, {
'Content-Length': fileStat.size,
'Content-Type': mimeTypes[extname(filePath).toLowerCase()] || 'application/octet-stream',
'X-Content-Type-Options': 'nosniff',
});
if (method === 'HEAD') {
response.end();
return;
}
createReadStream(filePath).pipe(response);
}
async function getFileStat(filePath) {
let fileStat;
try {
fileStat = await stat(filePath);
} catch {
throw httpError(404, 'Not found');
}
if (!fileStat.isFile()) {
throw httpError(404, 'Not found');
}
return fileStat;
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"admin:posts": "node ../local-admin/posts-admin.mjs",
"build": "next build",
"start": "next start"
},