Skip to content

TipTap, an AI assistant and auto-save: how we built the blog's content editor

How the editor in our CMS works: TipTap, AI-generated metadata, draft auto-save and image processing. By our measurements, post creation got about 40% faster.

AuthorIT Department
  • 7 min read
  • 210 views
TipTap, an AI assistant and auto-save: how we built the blog's content editor

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.

Tags

Share this article

Back to blog

Cookies

We use essential cookies and one functional cookie, the latter only with your consent. Visit statistics are counted on our server, without cookies. Cookie Policy