This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
.git
|
||||
.gitea
|
||||
.webarchive
|
||||
local-admin
|
||||
deploy
|
||||
web/.next
|
||||
web/node_modules
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { startServer } from './src/app/startServer.mjs';
|
||||
|
||||
startServer();
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function escapeAttribute(value) {
|
||||
return escapeHtml(value).replace(/`/g, '`');
|
||||
}
|
||||
@@ -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 ? `` : '';
|
||||
}
|
||||
|
||||
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; }
|
||||
`;
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const requestLimits = {
|
||||
maxBodySize: 30 * 1024 * 1024,
|
||||
maxUploadSize: 20 * 1024 * 1024,
|
||||
};
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"admin:posts": "node ../local-admin/posts-admin.mjs",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user