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

This commit is contained in:
2026-05-18 02:48:47 +03:00
parent 10a83d39a2
commit 3a3b2bcfc0
84 changed files with 2134 additions and 5 deletions
@@ -0,0 +1,4 @@
export function updateArticleButtons({ deleteButton, duplicateButton, state }) {
duplicateButton.disabled = !state.current;
deleteButton.disabled = !state.selectedSlug;
}
@@ -0,0 +1,17 @@
import { createArticleDraft } from '../article-editor/createArticleDraft.js';
import { fillArticleForm } from '../article-editor/form.js';
export function createNewArticle(context) {
if (!context.confirmDiscard()) {
return;
}
context.state.current = createArticleDraft({ articles: context.state.articles, assets: context.state.assets });
context.state.selectedSlug = null;
fillArticleForm(context.fields, context.state.current);
context.setStatus('Новая статья еще не сохранена');
context.setDirty(true);
context.updateButtons();
context.renderPreview();
context.renderArticles();
}
@@ -0,0 +1,29 @@
import { deleteArticle as deleteArticleRequest } from '../../entities/article/api.js';
export async function deleteArticle(context) {
if (!context.state.selectedSlug || !context.state.current) {
return;
}
const confirmed = window.confirm(`Удалить статью "${context.state.current.title}"? Это изменит articles.json.`);
if (!confirmed) {
return;
}
context.setStatus('Удаление...');
context.deleteButton.disabled = true;
try {
await deleteArticleRequest(context.state.selectedSlug);
context.state.current = null;
context.state.selectedSlug = null;
context.setDirty(false);
await context.reloadArticles(null);
context.setStatus('Статья удалена');
} catch (error) {
context.setStatus(error.message);
} finally {
context.deleteButton.disabled = false;
context.updateButtons();
}
}
@@ -0,0 +1,20 @@
import { fillArticleForm } from '../article-editor/form.js';
import { uniqueDraftSlug } from '../../shared/lib/slug.js';
export function duplicateArticle(context) {
if (!context.state.current || !context.confirmDiscard()) {
return;
}
const copy = structuredClone(context.state.current);
copy.slug = uniqueDraftSlug(`${copy.slug}-copy`, context.state.articles);
copy.title = `${copy.title} - копия`;
context.state.current = copy;
context.state.selectedSlug = null;
fillArticleForm(context.fields, copy);
context.setStatus('Дубликат еще не сохранен');
context.setDirty(true);
context.updateButtons();
context.renderPreview();
context.renderArticles();
}
@@ -0,0 +1,13 @@
import { getArticles } from '../../entities/article/api.js';
export async function reloadArticles(context, selectedSlug = context.state.selectedSlug) {
context.state.articles = await getArticles();
context.renderArticles();
if (selectedSlug && context.state.articles.some((article) => article.slug === selectedSlug)) {
context.selectArticle(selectedSlug, { skipConfirm: true });
return;
}
context.selectArticle(context.state.articles[0]?.slug || null, { skipConfirm: true });
}
@@ -0,0 +1,11 @@
import { renderArticleList } from '../../widgets/article-list/ui.js';
export function renderArticles({ articleList, searchInput, state }, onSelect) {
renderArticleList({
articles: state.articles,
container: articleList,
onSelect,
query: searchInput.value,
selectedSlug: state.selectedSlug,
});
}
@@ -0,0 +1,21 @@
import { createArticle, updateArticle } from '../../entities/article/api.js';
import { readArticleForm } from '../article-editor/form.js';
export async function saveArticle(context) {
const article = readArticleForm(context.fields, context.state.current);
const isNew = !context.state.selectedSlug;
context.setStatus('Сохранение...');
context.saveButton.disabled = true;
try {
const savedArticle = isNew ? await createArticle(article) : await updateArticle(context.state.selectedSlug, article);
await context.reloadArticles(savedArticle.slug);
context.setStatus(`Сохранено: ${savedArticle.title}`);
} catch (error) {
context.setStatus(error.message);
} finally {
context.saveButton.disabled = false;
context.updateButtons();
}
}
@@ -0,0 +1,28 @@
import { fillArticleForm } from '../article-editor/form.js';
export function selectArticle(slug, context, options = {}) {
if (!options.skipConfirm && !context.confirmDiscard()) {
return;
}
const article = context.state.articles.find((item) => item.slug === slug) || null;
context.state.current = article ? structuredClone(article) : null;
context.state.selectedSlug = article?.slug || null;
if (!context.state.current) {
context.form.reset();
context.setStatus('Нет статей');
context.setDirty(false);
context.updateButtons();
context.renderPreview();
context.renderArticles();
return;
}
fillArticleForm(context.fields, context.state.current);
context.setStatus(`Редактируется: ${context.state.current.title}`);
context.setDirty(false);
context.updateButtons();
context.renderPreview();
context.renderArticles();
}
@@ -0,0 +1,15 @@
import { markdownToHtml } from '../../shared/lib/markdown.js';
export function createArticleDraft({ articles, assets }) {
return {
slug: `new-post-${Date.now()}`,
title: 'Новая статья',
date: new Date().toISOString(),
author: articles[0]?.author || 'DarkRiDDeR',
categories: articles[0]?.categories || ['Разное'],
cover: assets[0] || '/assets/illustrations/vibe-cover.svg',
excerpt: 'Короткий анонс статьи.',
contentHtml: markdownToHtml('Текст статьи.'),
readingMinutes: 1,
};
}
@@ -0,0 +1,13 @@
import { htmlToMarkdown } from '../../shared/lib/markdown.js';
export function fillArticleForm(fields, article) {
fields.title.value = article.title || '';
fields.slug.value = article.slug || '';
fields.date.value = article.date || '';
fields.author.value = article.author || '';
fields.categories.value = (article.categories || []).join(', ');
fields.cover.value = article.cover || '';
fields.excerpt.value = article.excerpt || '';
fields.contentMarkdown.value = article.contentMarkdown || htmlToMarkdown(article.contentHtml || '');
fields.readingMinutes.value = article.readingMinutes || 1;
}
@@ -0,0 +1,2 @@
export { fillArticleForm } from './fillArticleForm.js';
export { readArticleForm } from './readArticleForm.js';
@@ -0,0 +1,22 @@
import { markdownToHtml } from '../../shared/lib/markdown.js';
export function readArticleForm(fields, currentArticle) {
const article = {
...(currentArticle || {}),
slug: fields.slug.value.trim(),
title: fields.title.value.trim(),
date: fields.date.value.trim(),
author: fields.author.value.trim(),
categories: fields.categories.value
.split(',')
.map((category) => category.trim())
.filter(Boolean),
cover: fields.cover.value.trim(),
excerpt: fields.excerpt.value.trim(),
contentHtml: markdownToHtml(fields.contentMarkdown.value),
readingMinutes: Number(fields.readingMinutes.value),
};
delete article.contentMarkdown;
return article;
}
@@ -0,0 +1,5 @@
export function mergeAssetPaths(currentAssets, uploadedAssets) {
return [...new Set([...uploadedAssets.map((asset) => asset.path), ...currentAssets])].sort((left, right) =>
left.localeCompare(right, 'ru'),
);
}
@@ -0,0 +1,9 @@
export function renderAssetOptions(assetOptions, assets) {
assetOptions.replaceChildren(
...assets.map((asset) => {
const option = document.createElement('option');
option.value = asset;
return option;
}),
);
}
@@ -0,0 +1,4 @@
export function assetToMarkdown(asset, file) {
const label = file.name.replace(/\.[^.]+$/, '') || asset.fileName;
return asset.isImage ? `![${label}](${asset.path})` : `[${label}](${asset.path})`;
}
@@ -0,0 +1,21 @@
import { uploadAsset } from '../../entities/asset/api.js';
import { readFileAsDataUrl } from '../../shared/lib/file.js';
export async function uploadFiles(files, options) {
const uploaded = [];
options.setStatus(`Загрузка файлов: ${files.length}`);
try {
for (const file of files) {
const dataUrl = await readFileAsDataUrl(file);
const asset = await uploadAsset({ dataUrl, fileName: file.name });
uploaded.push(asset);
options.onUploaded(asset, file);
}
options.onComplete(uploaded);
options.setStatus(`${options.statusPrefix}: ${uploaded.map((asset) => asset.path).join(', ')}`);
} catch (error) {
options.setStatus(error.message);
}
}
@@ -0,0 +1,26 @@
import { uploadFiles } from './upload-assets.js';
export async function uploadCover(context) {
const [file] = context.coverUploadInput.files || [];
context.coverUploadInput.value = '';
if (!file) {
return;
}
if (!file.type.startsWith('image/')) {
context.setStatus('Для обложки можно загрузить только изображение');
return;
}
await uploadFiles([file], {
onComplete: context.mergeUploadedAssets,
onUploaded(asset) {
context.fields.cover.value = asset.path;
context.setDirty(true);
context.renderPreview();
},
setStatus: context.setStatus,
statusPrefix: 'Обложка загружена',
});
}
@@ -0,0 +1,28 @@
import { insertAtCursor } from '../markdown-editor/formatting.js';
import { assetToMarkdown } from './assetToMarkdown.js';
import { uploadFiles } from './upload-assets.js';
export async function uploadMarkdownAssets(context) {
const files = Array.from(context.assetUploadInput.files || []);
context.assetUploadInput.value = '';
if (files.length === 0) {
return;
}
await uploadFiles(files, {
onComplete: context.mergeUploadedAssets,
onUploaded(asset, file) {
insertAtCursor({
field: context.fields.contentMarkdown,
onChange() {
context.setDirty(true);
context.renderPreview();
},
text: `${assetToMarkdown(asset, file)}\n`,
});
},
setStatus: context.setStatus,
statusPrefix: 'Ассет загружен',
});
}
@@ -0,0 +1,30 @@
import { prefixSelectionLines } from './prefixSelectionLines.js';
import { wrapSelection } from './wrapSelection.js';
export function applyMarkdownFormat({ field, format, onChange }) {
if (format === 'link') {
applyLinkFormat({ field, onChange });
return;
}
const actions = {
bold: () => wrapSelection({ fallback: 'жирный текст', field, onChange, prefix: '**', suffix: '**' }),
code: () => wrapSelection({ fallback: 'code', field, onChange, prefix: '`', suffix: '`' }),
h2: () => prefixSelectionLines({ field, onChange, prefix: '## ' }),
h3: () => prefixSelectionLines({ field, onChange, prefix: '### ' }),
italic: () => wrapSelection({ fallback: 'курсив', field, onChange, prefix: '*', suffix: '*' }),
list: () => prefixSelectionLines({ field, onChange, prefix: '- ' }),
quote: () => prefixSelectionLines({ field, onChange, prefix: '> ' }),
};
actions[format]?.();
}
function applyLinkFormat({ field, onChange }) {
const url = window.prompt('URL ссылки');
if (!url) {
return;
}
wrapSelection({ fallback: 'текст ссылки', field, onChange, prefix: '[', suffix: `](${url})` });
}
@@ -0,0 +1,7 @@
import { applyMarkdownFormat } from './applyMarkdownFormat.js';
export function bindMarkdownToolbar({ field, onChange, root = document }) {
root.querySelectorAll('[data-format]').forEach((button) => {
button.addEventListener('click', () => applyMarkdownFormat({ field, format: button.dataset.format, onChange }));
});
}
@@ -0,0 +1,2 @@
export { bindMarkdownToolbar } from './bindMarkdownToolbar.js';
export { insertAtCursor } from './insertAtCursor.js';
@@ -0,0 +1,14 @@
export function insertAtCursor({ field, onChange, text }) {
const start = field.selectionStart;
const end = field.selectionEnd;
const before = field.value.slice(0, start);
const after = field.value.slice(end);
const separatorBefore = before && !before.endsWith('\n') ? '\n' : '';
const separatorAfter = after && !after.startsWith('\n') ? '\n' : '';
field.value = `${before}${separatorBefore}${text}${separatorAfter}${after}`;
const cursor = before.length + separatorBefore.length + text.length;
field.focus();
field.setSelectionRange(cursor, cursor);
onChange();
}
@@ -0,0 +1,15 @@
export function prefixSelectionLines({ field, onChange, prefix }) {
const start = field.selectionStart;
const end = field.selectionEnd;
const lineStart = field.value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
const selected = field.value.slice(lineStart, end) || 'текст';
const replacement = selected
.split('\n')
.map((line) => (line.startsWith(prefix) ? line : `${prefix}${line}`))
.join('\n');
field.value = `${field.value.slice(0, lineStart)}${replacement}${field.value.slice(end)}`;
field.focus();
field.setSelectionRange(lineStart, lineStart + replacement.length);
onChange();
}
@@ -0,0 +1,11 @@
export function wrapSelection({ fallback, field, onChange, prefix, suffix }) {
const start = field.selectionStart;
const end = field.selectionEnd;
const selected = field.value.slice(start, end) || fallback;
const nextValue = `${field.value.slice(0, start)}${prefix}${selected}${suffix}${field.value.slice(end)}`;
field.value = nextValue;
field.focus();
field.setSelectionRange(start + prefix.length, start + prefix.length + selected.length);
onChange();
}
@@ -0,0 +1,19 @@
import { markdownToHtml, previewStyles } from '../../shared/lib/markdown.js';
export function renderPreview({ contentPreview, coverPreview, fields, openArticleLink }) {
const cover = fields.cover.value.trim();
coverPreview.src = cover || '';
coverPreview.hidden = !cover;
contentPreview.srcdoc = createPreviewDocument(fields.contentMarkdown.value);
updateArticleLink(openArticleLink, fields.slug.value.trim());
}
function createPreviewDocument(markdown) {
return `<!doctype html><html lang="ru"><head><meta charset="utf-8"><style>${previewStyles}</style></head><body>${markdownToHtml(markdown)}</body></html>`;
}
function updateArticleLink(openArticleLink, slug) {
openArticleLink.href = slug ? `http://localhost:3000/articles/${encodeURIComponent(slug)}/` : '#';
openArticleLink.hidden = !slug;
}
@@ -0,0 +1,20 @@
export function setDirty({ dirty, saveButton, state }) {
state.dirty = dirty;
document.title = `${dirty ? '* ' : ''}Локальная админка постов`;
saveButton.textContent = dirty ? 'Сохранить *' : 'Сохранить';
}
export function confirmDiscard(state) {
return !state.dirty || window.confirm('Есть несохраненные изменения. Сбросить их?');
}
export function protectUnsavedChanges(state) {
window.addEventListener('beforeunload', (event) => {
if (!state.dirty) {
return;
}
event.preventDefault();
event.returnValue = '';
});
}
@@ -0,0 +1,3 @@
export function setStatus(statusText, message) {
statusText.textContent = message;
}