This commit is contained in:
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user