This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
.git
|
.git
|
||||||
.gitea
|
.gitea
|
||||||
.webarchive
|
.webarchive
|
||||||
|
local-admin
|
||||||
deploy
|
deploy
|
||||||
web/.next
|
web/.next
|
||||||
web/node_modules
|
web/node_modules
|
||||||
|
|||||||
@@ -14,11 +14,20 @@ Useful commands:
|
|||||||
```bash
|
```bash
|
||||||
cd ~/tmp/progcode/web
|
cd ~/tmp/progcode/web
|
||||||
npm install
|
npm install
|
||||||
|
ADMIN_PORT=3333 npm run admin:posts
|
||||||
node scripts/buildDatabase.mjs
|
node scripts/buildDatabase.mjs
|
||||||
npm run dev -- --port 3000
|
npm run dev -- --port 3000
|
||||||
npm run build
|
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:
|
Docker static deploy:
|
||||||
|
|
||||||
```bash
|
```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,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"admin:posts": "node ../local-admin/posts-admin.mjs",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user