Content management system: from editor to AI assistant
From the CEO of IT Compass
The first article was about the architecture of our blog. This one is about the part authors actually touch: the editor. I will show how we built it, how AI takes over routine work, and how the auto-save system works.
Choosing an editor: why TipTap?
We looked at Quill, Draft.js and CKEditor, and settled on TipTap. The reasons:
It is built on ProseMirror, a solid base
It has very good Vue.js support
The architecture is modular
We keep full control over the functionality
TipTap editor integration
This is what our editor component looks like:
<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">
<!-- Toolbar -->
<button
type="button"
@click="editor?.chain().focus().toggleBold().run()"
:class="buttonClass(editor?.isActive('bold'))"
>
<Lucide icon="Bold" class="w-4 h-4" />
</button>
<!-- Other formatting buttons -->
</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 assistant for metadata generation
One of the most useful features is AI generation of SEO metadata. Writing titles, descriptions and keywords by hand is tedious, so the AI reads the content and suggests options for the author to pick from.
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
{
// Content preprocessing
$processedContent = $this->preprocessContent($content, $contentType, $language);
// Building prompt
$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";
}
}
Content processing
Before content goes to the AI, we prepare it:
class ContentProcessor
{
protected int $maxTokens;
public function processForAI(string $htmlContent, string $language, array $options): string
{
// HTML cleaning
$cleanHtml = $this->cleanHtml($htmlContent);
// Key content extraction
$extractedContent = $this->extractKeyContent($cleanHtml, $options);
// Conversion to processed text
$processedText = $this->convertToProcessedText($extractedContent, $language);
// Compression to token limit
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' => [], // Headers
'important' => [], // Highlighted text
'regular' => [], // Regular paragraphs
'lists' => [] // Lists
];
// Extract headers with weights
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;
}
}
Draft auto-save
Every author knows the feeling of losing unsaved work. Auto-save removes that risk:
// Composable for draft management
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,
// ... other fields
};
draftStore.saveDraft(draftData, postId);
lastSaveTime.value = Date.now();
};
// Auto-save every 30 seconds
const startAutosave = () => {
autosaveInterval.value = setInterval(() => {
saveDraft();
}, 30000);
};
// Form change tracking
watch(
() => [form.contents, form.categories, form.tags, /* ... */],
() => {
handleFormChange();
},
{ deep: true, flush: 'post' }
);
return {
saveDraft,
loadDraft,
clearDraft,
hasDraft,
formatLastSaveTime
};
}
Pinia store for drafts
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);
// Deep cloning to avoid reactivity issues
const draftData = JSON.parse(JSON.stringify(formData));
drafts.value[key] = draftData;
lastSaved.value[key] = Date.now();
// Saving to 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
};
});
Draft recovery component
<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>
Image processing and optimization
For each image we create three versions, so every page loads only what it needs:
class ImageProcessing
{
private const SCALES = [
'low' => 400, // For previews and mobile
'medium' => 800, // For normal viewing
'full' => null // Original resolution
];
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);
}
// Convert to WebP for optimal compression
$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;
}
}
}
Putting the components together
This is how the parts fit together in the post creation form:
<script setup lang="ts">
// Compositions for different form aspects
const { form, saveDraft, submitForm } = usePostForm(props.post, initializeContents);
const { hasDraft, loadDraft, clearDraft } = usePostDraft(form, props.post);
const { aiLoading, aiError, launchAI } = usePostAI(form, activeLocale);
// Check for draft on mount
onMounted(() => {
if (shouldShowDraftWarning.value) {
showDraftRecoveryModal.value = true;
}
});
// Handle AI generation
const handleAIGeneration = async () => {
await launchAI();
// AI will automatically update form fields
};
// Form submission
const handleSubmit = () => {
clearDraft(); // Clear draft on successful submission
submitForm();
};
</script>
Results
After rolling this out, we measured the following:
Post creation time down by 40%, thanks to AI metadata generation
No cases of data loss since auto-save went in
CTR up by 25%, which we put down to the optimized SEO titles
Server load down by 30%, thanks to image optimization
Takeaways
A good content system is more than a nice-looking editor. In our case it came down to four things:
AI takes over routine tasks
Auto-save protects against data loss
Media is optimized so pages load fast
The interface is built around what authors actually do
In the next article I will cover how we kept the system fast under high load: asynchronous processing, view counting and caching.
To be continued.



