W pierwszym artykule pisałem o architekturze naszego bloga. Ten dotyczy części, z którą autorzy mają do czynienia na co dzień: edytora. Pokażę, jak go zbudowaliśmy, jak AI przejmuje rutynowe zadania i jak działa autozapis.
Wybór edytora: dlaczego TipTap?
Rozważaliśmy Quill, Draft.js i CKEditor, a skończyliśmy na TipTap. Powody:
Jest zbudowany na ProseMirror, solidnej bazie
Bardzo dobrze współpracuje z Vue.js
Ma modularną architekturę
Daje pełną kontrolę nad funkcjonalnością
Integracja edytora TipTap
Tak wygląda nasz komponent edytora:
<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">
<!-- Panel narzędzi -->
<button
type="button"
@click="editor?.chain().focus().toggleBold().run()"
:class="buttonClass(editor?.isActive('bold'))"
>
<Lucide icon="Bold" class="w-4 h-4" />
</button>
<!-- Inne przyciski formatowania -->
</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>
Asystent AI do generowania metadanych
Jedna z najbardziej przydatnych funkcji to generowanie metadanych SEO przez AI. Ręczne wymyślanie tytułów, opisów i słów kluczowych jest nudne, więc AI czyta treść i proponuje warianty, z których autor wybiera najlepszy.
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
{
// Wstępne przetwarzanie treści
$processedContent = $this->preprocessContent($content, $contentType, $language);
// Budowanie prompta
$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";
}
}
Przetwarzanie treści przed wysłaniem do AI
Zanim treść trafi do AI, odpowiednio ją przygotowujemy:
class ContentProcessor
{
protected int $maxTokens;
public function processForAI(string $htmlContent, string $language, array $options): string
{
// Czyszczenie HTML
$cleanHtml = $this->cleanHtml($htmlContent);
// Wyciągnięcie kluczowej treści
$extractedContent = $this->extractKeyContent($cleanHtml, $options);
// Konwersja do przetworzonego tekstu
$processedText = $this->convertToProcessedText($extractedContent, $language);
// Kompresja do limitu tokenów
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' => [], // Nagłówki
'important' => [], // Wyróżniony tekst
'regular' => [], // Zwykłe paragrafy
'lists' => [] // Listy
];
// Wyciągamy nagłówki z wagami
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;
}
}
Autozapis szkiców
Każdy autor zna to uczucie, gdy znika niezapisana praca. Autozapis usuwa to ryzyko:
// Composable do zarządzania szkicami
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,
// ... inne pola
};
draftStore.saveDraft(draftData, postId);
lastSaveTime.value = Date.now();
};
// Automatyczny zapis co 30 sekund
const startAutosave = () => {
autosaveInterval.value = setInterval(() => {
saveDraft();
}, 30000);
};
// Śledzenie zmian formularza
watch(
() => [form.contents, form.categories, form.tags, /* ... */],
() => {
handleFormChange();
},
{ deep: true, flush: 'post' }
);
return {
saveDraft,
loadDraft,
clearDraft,
hasDraft,
formatLastSaveTime
};
}
Pinia Store dla szkiców
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);
// Głębokie klonowanie dla uniknięcia problemów z reaktywnością
const draftData = JSON.parse(JSON.stringify(formData));
drafts.value[key] = draftData;
lastSaved.value[key] = Date.now();
// Zapisywanie w 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
};
});
Komponent odzyskiwania szkicu
<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>
Przetwarzanie i optymalizacja obrazów
Dla każdego obrazu tworzymy trzy wersje, żeby strona ładowała tylko to, czego potrzebuje:
class ImageProcessing
{
private const SCALES = [
'low' => 400, // Dla podglądów i urządzeń mobilnych
'medium' => 800, // Dla normalnego wyświetlania
'full' => null // Oryginalna rozdzielczość
];
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);
}
// Konwersja do WebP dla optymalnej kompresji
$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;
}
}
}
Składamy komponenty w całość
Tak te elementy współpracują w formularzu tworzenia wpisu:
<script setup lang="ts">
// Kompozycje dla różnych aspektów formularza
const { form, saveDraft, submitForm } = usePostForm(props.post, initializeContents);
const { hasDraft, loadDraft, clearDraft } = usePostDraft(form, props.post);
const { aiLoading, aiError, launchAI } = usePostAI(form, activeLocale);
// Podczas montowania sprawdzamy obecność szkicu
onMounted(() => {
if (shouldShowDraftWarning.value) {
showDraftRecoveryModal.value = true;
}
});
// Obsługa generowania AI
const handleAIGeneration = async () => {
await launchAI();
// AI automatycznie zaktualizuje pola formularza
};
// Wysyłanie formularza
const handleSubmit = () => {
clearDraft(); // Czyścimy szkic po pomyślnym wysłaniu
submitForm();
};
</script>
Wyniki
Po wdrożeniu zmierzyliśmy:
Czas tworzenia wpisu krótszy o 40% dzięki generowaniu metadanych przez AI
Zero przypadków utraty danych od wprowadzenia autozapisu
CTR wyższy o 25%, co przypisujemy zoptymalizowanym tytułom SEO
Obciążenie serwera niższe o 30% dzięki optymalizacji obrazów
Wnioski
Dobry system zarządzania treścią to coś więcej niż ładny edytor. U nas sprowadziło się to do czterech rzeczy:
AI przejmuje rutynowe zadania
Autozapis chroni przed utratą danych
Media są zoptymalizowane, więc strony ładują się szybko
Interfejs jest zbudowany wokół tego, co autorzy faktycznie robią
W następnym artykule opowiem, jak utrzymaliśmy wydajność systemu pod dużym obciążeniem: przetwarzanie asynchroniczne, liczenie wyświetleń i cachowanie.
Ciąg dalszy nastąpi.



