У першій статті я розповів про архітектуру нашого блогу. Ця присвячена тому, з чим автори працюють щодня: редактору. Покажу, як ми його зробили, як AI бере на себе рутину і як працює автозбереження.
Вибір редактора: чому TipTap?
Ми розглядали Quill, Draft.js і CKEditor, а зупинилися на TipTap. Причини:
Він побудований на ProseMirror, надійній основі
Дуже добре працює з Vue.js
Має модульну архітектуру
Дає повний контроль над функціональністю
Інтеграція TipTap Editor
Ось як виглядає наш компонент редактора:
<template>
<div class="tiptap-editor border rounded-md">
<div class="sticky top-16 z-10 flex flex-wrap items-center gap-1 p-3
border-b bg-slate-50/50 dark:bg-darkmode-600">
<!-- Панель інструментів -->
<button
type="button"
@click="editor?.chain().focus().toggleBold().run()"
:class="buttonClass(editor?.isActive('bold'))"
>
<Lucide icon="Bold" class="w-4 h-4" />
</button>
<!-- Інші кнопки форматування -->
</div>
<EditorContent
:editor="editor"
:class="editorContentClass"
/>
<div v-if="showCharacterCount" class="text-right text-xs p-2">
{{ characterCount }} {{ $trans('main.blog.posts.characters') }}
<span v-if="characterLimit"> / {{ characterLimit }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import TextAlign from '@tiptap/extension-text-align'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import CharacterCount from '@tiptap/extension-character-count'
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Image.configure({
HTMLAttributes: {
class: 'max-w-full h-auto rounded-lg',
},
}),
Link.configure({
openOnClick: false,
}),
CharacterCount.configure({
limit: 15000,
}),
],
onUpdate: ({ editor }) => {
const htmlContent = editor.getHTML()
const textContent = editor.getText()
emit('update:modelValue', htmlContent)
emit('update:text', textContent)
},
})
</script>
AI-асистент для генерації метаданих
Одна з найкорисніших функцій: генерація SEO-метаданих через AI. Вигадувати заголовки, описи й ключові слова вручну нудно, тож AI читає контент і пропонує варіанти, з яких автор обирає найкращий.
class AIGenerator
{
protected Client $client;
protected ContentProcessor $contentProcessor;
public function __construct(ContentProcessor $contentProcessor)
{
$this->client = OpenAI::client(env('OPENAI_API_KEY'));
$this->contentProcessor = $contentProcessor;
}
public function generate(string $content, string $contentType, string $language): array
{
// Попередня обробка контенту
$processedContent = $this->preprocessContent($content, $contentType, $language);
// Побудова промпта
$prompt = $this->buildPrompt($processedContent, $language);
$response = $this->client->chat()->create([
'model' => env('OPENAI_MODEL'),
'messages' => [
[
'role' => 'system',
'content' => $this->getSystemPrompt($language)
],
[
'role' => 'user',
'content' => $prompt
]
],
'temperature' => 0.7,
'max_tokens' => 4000,
]);
return $this->parseGeneratedContent($response->choices[0]->message->content);
}
protected function buildPrompt(string $content, string $language): string
{
$languageMap = [
'en' => 'English',
'pl' => 'Polish',
'uk' => 'Ukrainian'
];
$targetLanguage = $languageMap[$language] ?? 'English';
return "Based on the following content, generate SEO metadata in {$targetLanguage}:
Content: {$content}
Generate metadata with the following JSON structure:
{
\"title\": \"Engaging title (10-255 characters)\",
\"excerpt\": \"Compelling summary (50-65535 characters)\",
\"slug\": \"url-friendly-slug\",
\"meta_title\": \"SEO optimized title (10-255 characters)\",
\"meta_description\": \"SEO description (120-255 characters)\",
\"keywords\": [\"keyword1\", \"keyword2\", \"...\"]
}
Requirements:
- Extract keywords from the actual content
- Create compelling, clickable titles
- Include call-to-action in meta descriptions
- Make slug SEO-friendly and readable
- Return ONLY valid JSON";
}
}
Обробка контенту перед відправкою в AI
Перш ніж контент потрапить до AI, ми його готуємо:
class ContentProcessor
{
protected int $maxTokens;
public function processForAI(string $htmlContent, string $language, array $options): string
{
// Очищення HTML
$cleanHtml = $this->cleanHtml($htmlContent);
// Витяг ключового контенту
$extractedContent = $this->extractKeyContent($cleanHtml, $options);
// Конвертація в оброблений текст
$processedText = $this->convertToProcessedText($extractedContent, $language);
// Стиснення до ліміту токенів
return $this->compressToTokenLimit($processedText, $language);
}
protected function extractKeyContent(string $html, array $options): array
{
$dom = new DOMDocument('1.0', 'UTF-8');
@$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
$extractedContent = [
'priority' => [], // Заголовки
'important' => [], // Виділений текст
'regular' => [], // Звичайні параграфи
'lists' => [] // Списки
];
// Витягуємо заголовки з вагами
foreach(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $tag) {
$elements = $xpath->query("//{$tag}");
foreach($elements as $element) {
$text = $this->cleanText($element->textContent);
if(strlen($text) > 5) {
$extractedContent['priority'][] = [
'tag' => $tag,
'text' => $text,
'weight' => $this->getElementWeight($tag)
];
}
}
}
return $extractedContent;
}
}
Автозбереження чернеток
Кожен автор знає, як неприємно втратити незбережену роботу. Автозбереження прибирає цей ризик:
// Composable для управління чернетками
export function usePostDraft(form: any, post?: FormPost) {
const draftStore = usePostDraftStore();
const { $trans } = useTranslation();
const autosaveInterval = ref<NodeJS.Timeout | null>(null);
const lastSaveTime = ref<number | null>(null);
const saveDraft = () => {
if (!draftStore.autosaveEnabled) return;
const draftData: PostDraftState = {
author_id: form.author_id,
scheduled_at: form.scheduled_at,
publish_now: form.publish_now,
contents: form.contents,
categories: form.categories,
tags: form.tags,
enable_fake_views: form.enable_fake_views,
fake_view_count: form.fake_view_count,
views_increment_type: form.views_increment_type,
// ... інші поля
};
draftStore.saveDraft(draftData, postId);
lastSaveTime.value = Date.now();
};
// Автозбереження кожні 30 секунд
const startAutosave = () => {
autosaveInterval.value = setInterval(() => {
saveDraft();
}, 30000);
};
// Відстеження змін форми
watch(
() => [form.contents, form.categories, form.tags, /* ... */],
() => {
handleFormChange();
},
{ deep: true, flush: 'post' }
);
return {
saveDraft,
loadDraft,
clearDraft,
hasDraft,
formatLastSaveTime
};
}
Pinia Store для чернеток
export const usePostDraftStore = defineStore('postDraft', () => {
const drafts = ref<Record<string, PostDraftState>>({});
const autosaveEnabled = ref(true);
const lastSaved = ref<Record<string, number>>({});
const getDraftKey = (postId?: number) => {
return postId ? `edit_${postId}` : 'create_new';
};
const saveDraft = (formData: PostDraftState, postId?: number) => {
if (!autosaveEnabled.value) return;
const key = getDraftKey(postId);
// Глибоке клонування для уникнення проблем з реактивністю
const draftData = JSON.parse(JSON.stringify(formData));
drafts.value[key] = draftData;
lastSaved.value[key] = Date.now();
// Збереження в localStorage
try {
localStorage.setItem('post_drafts', JSON.stringify(drafts.value));
localStorage.setItem('post_drafts_timestamps', JSON.stringify(lastSaved.value));
} catch (error) {
console.error('Failed to save draft to localStorage:', error);
}
};
return {
drafts,
saveDraft,
loadDraft,
clearDraft,
hasDraft,
getLastSavedTime
};
});
Компонент відновлення чернетки
<template>
<Dialog :open="open" @close="handleClose">
<Dialog.Panel class="max-w-md">
<Dialog.Title>
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<Lucide icon="FileText" class="w-5 h-5 text-blue-600" />
</div>
<div>
<h2 class="text-lg font-semibold">
{{ $trans('main.blog.posts.draft_recovery_title') }}
</h2>
<p class="text-sm text-slate-600 mt-1">
{{ lastSaveTime }}
</p>
</div>
</div>
</Dialog.Title>
<Dialog.Description>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p class="text-sm font-medium text-blue-800">
{{ $trans('main.blog.posts.draft_found_message') }}
</p>
</div>
</Dialog.Description>
<Dialog.Footer class="flex gap-3">
<Button variant="outline-secondary" @click="handleStartFresh">
<Lucide icon="FileX" class="w-4 h-4 mr-2" />
{{ $trans('main.blog.posts.start_fresh') }}
</Button>
<Button variant="primary" @click="handleRestoreDraft">
<Lucide icon="RotateCcw" class="w-4 h-4 mr-2" />
{{ $trans('main.blog.posts.restore_draft') }}
</Button>
</Dialog.Footer>
</Dialog.Panel>
</Dialog>
</template>
Обробка та оптимізація зображень
Для кожного зображення ми створюємо три версії, щоб сторінка завантажувала лише потрібне:
class ImageProcessing
{
private const SCALES = [
'low' => 400, // Для прев'ю та мобільних
'medium' => 800, // Для звичайного перегляду
'full' => null // Оригінальна роздільність
];
public function processAndSaveImage(UploadedFile $uploadedFile): ?array
{
try {
$imagick = new Imagick($uploadedFile->getRealPath());
$originalWidth = $imagick->getImageWidth();
$originalHeight = $imagick->getImageHeight();
$scalePaths = [];
foreach(self::SCALES as $scale => $maxWidth) {
$scaledImage = clone $imagick;
if($maxWidth !== null && $originalWidth > $maxWidth) {
$scaledImage->scaleImage($maxWidth, 0);
}
// Конвертація в WebP для оптимального стиснення
$scaledImage->setImageCompressionQuality(85);
$scaledImage->setImageFormat('WEBP');
$filename = $this->generateRandomFilename() . '_' . $scale . '.webp';
$filePath = 'posts/' . $filename;
Storage::disk('landing')->put(
$filePath,
$scaledImage->getImageBlob()
);
$scalePaths[$scale] = $filePath;
$scaledImage->destroy();
}
$imagick->destroy();
return $scalePaths;
} catch(ImagickException $e) {
Log::error('Image processing failed: ' . $e->getMessage());
return null;
}
}
}
Збираємо компоненти разом
Ось як ці частини взаємодіють у формі створення посту:
<script setup lang="ts">
// Композиції для різних аспектів форми
const { form, saveDraft, submitForm } = usePostForm(props.post, initializeContents);
const { hasDraft, loadDraft, clearDraft } = usePostDraft(form, props.post);
const { aiLoading, aiError, launchAI } = usePostAI(form, activeLocale);
// При монтуванні перевіряємо наявність чернетки
onMounted(() => {
if (shouldShowDraftWarning.value) {
showDraftRecoveryModal.value = true;
}
});
// Обробка AI-генерації
const handleAIGeneration = async () => {
await launchAI();
// AI автоматично оновить поля форми
};
// Відправка форми
const handleSubmit = () => {
clearDraft(); // Очищуємо чернетку при успішній відправці
submitForm();
};
</script>
Результати
Після впровадження ми виміряли таке:
Час створення посту менший на 40% завдяки генерації метаданих через AI
Жодного випадку втрати даних після появи автозбереження
CTR вищий на 25%, що ми пов'язуємо з оптимізованими SEO-заголовками
Навантаження на сервер нижче на 30% завдяки оптимізації зображень
Висновки
Хороша система керування контентом: це більше, ніж гарний редактор. У нашому випадку все звелося до чотирьох речей:
AI бере на себе рутинні завдання
Автозбереження захищає від втрати даних
Медіа оптимізовані, тому сторінки завантажуються швидко
Інтерфейс побудований навколо того, що автори справді роблять
У наступній статті розповім, як ми втримали продуктивність системи під високим навантаженням: асинхронна обробка, підрахунок переглядів і кешування.
Продовження буде.



