feat: implement real-time SEO character counters and improve default avatar handling and translation logic.

This commit is contained in:
Ümit Tunç
2026-08-07 23:25:40 +03:00
parent 32b3f5187d
commit a51af8208d
7 changed files with 91 additions and 5 deletions
@@ -146,12 +146,20 @@ class BlogForm
TextInput::make('meta_title') TextInput::make('meta_title')
->label(__('blog.meta_title_field')) ->label(__('blog.meta_title_field'))
->maxLength(60) ->maxLength(60)
->extraInputAttributes(['maxlength' => 60])
->live(debounce: 200)
->hint(fn ($state) => mb_strlen((string) $state) . ' / 60')
->hintColor(fn ($state) => mb_strlen((string) $state) > 60 ? 'danger' : 'gray')
->helperText(__('blog.meta_title_helper')), ->helperText(__('blog.meta_title_helper')),
Textarea::make('meta_description') Textarea::make('meta_description')
->label(__('blog.meta_description_field')) ->label(__('blog.meta_description_field'))
->rows(3) ->rows(3)
->maxLength(160) ->maxLength(160)
->extraInputAttributes(['maxlength' => 160])
->live(debounce: 200)
->hint(fn ($state) => mb_strlen((string) $state) . ' / 160')
->hintColor(fn ($state) => mb_strlen((string) $state) > 160 ? 'danger' : 'gray')
->helperText(__('blog.meta_description_helper')) ->helperText(__('blog.meta_description_helper'))
->columnSpanFull(), ->columnSpanFull(),
]) ])
@@ -15,12 +15,20 @@ class SeoSection
TextInput::make('meta_title') TextInput::make('meta_title')
->label(__('pages.meta_title_field')) ->label(__('pages.meta_title_field'))
->maxLength(60) ->maxLength(60)
->extraInputAttributes(['maxlength' => 60])
->live(debounce: 200)
->hint(fn ($state) => mb_strlen((string) $state) . ' / 60')
->hintColor(fn ($state) => mb_strlen((string) $state) > 60 ? 'danger' : 'gray')
->helperText(__('pages.meta_title_helper_text')), ->helperText(__('pages.meta_title_helper_text')),
Textarea::make('meta_description') Textarea::make('meta_description')
->label(__('pages.meta_description_field')) ->label(__('pages.meta_description_field'))
->rows(3) ->rows(3)
->maxLength(160) ->maxLength(160)
->extraInputAttributes(['maxlength' => 160])
->live(debounce: 200)
->hint(fn ($state) => mb_strlen((string) $state) . ' / 160')
->hintColor(fn ($state) => mb_strlen((string) $state) > 160 ? 'danger' : 'gray')
->helperText(__('pages.meta_description_helper_text')) ->helperText(__('pages.meta_description_helper_text'))
->columnSpanFull(), ->columnSpanFull(),
]) ])
+6 -2
View File
@@ -156,6 +156,8 @@ class CareerController extends Controller
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'intern_category' => 'required|string|in:experience,technical_challenge,product_showcase', 'intern_category' => 'required|string|in:experience,technical_challenge,product_showcase',
'excerpt' => 'nullable|string|max:500', 'excerpt' => 'nullable|string|max:500',
'meta_title' => 'nullable|string|max:60',
'meta_description' => 'nullable|string|max:160',
'content' => 'required|string|min:50', 'content' => 'required|string|min:50',
'featured_image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:5120', 'featured_image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:5120',
'action_type' => 'required|string|in:draft,submit', 'action_type' => 'required|string|in:draft,submit',
@@ -164,6 +166,8 @@ class CareerController extends Controller
'intern_category.required' => 'Lütfen bir kategori seçin.', 'intern_category.required' => 'Lütfen bir kategori seçin.',
'content.required' => 'Lütfen blog yazısı içeriğini girin.', 'content.required' => 'Lütfen blog yazısı içeriğini girin.',
'content.min' => 'Blog içeriği en az 50 karakter olmalıdır.', 'content.min' => 'Blog içeriği en az 50 karakter olmalıdır.',
'meta_title.max' => 'Meta Başlık en fazla 60 karakter olmalıdır.',
'meta_description.max' => 'Meta Açıklama en fazla 160 karakter olmalıdır.',
'featured_image.image' => 'Görsel geçerli bir resim dosyası olmalıdır.', 'featured_image.image' => 'Görsel geçerli bir resim dosyası olmalıdır.',
]); ]);
@@ -195,8 +199,8 @@ class CareerController extends Controller
$blog->excerpt = $request->excerpt; $blog->excerpt = $request->excerpt;
$blog->content = $request->content; $blog->content = $request->content;
$blog->status = $status; $blog->status = $status;
$blog->meta_title = $request->title; $blog->meta_title = $request->filled('meta_title') ? Str::limit($request->meta_title, 60, '') : Str::limit($request->title, 60, '');
$blog->meta_description = Str::limit(strip_tags($request->excerpt ?: $request->content), 160); $blog->meta_description = $request->filled('meta_description') ? Str::limit($request->meta_description, 160, '') : Str::limit(strip_tags($request->excerpt ?: $request->content), 160, '');
if ($request->hasFile('featured_image')) { if ($request->hasFile('featured_image')) {
if ($blog->featured_image && Storage::disk('public')->exists($blog->featured_image)) { if ($blog->featured_image && Storage::disk('public')->exists($blog->featured_image)) {
+1 -1
View File
@@ -109,7 +109,7 @@ class Blog extends Model
if ($this->author && $this->author->avatar) { if ($this->author && $this->author->avatar) {
return asset('storage/' . $this->author->avatar); return asset('storage/' . $this->author->avatar);
} }
return asset('assets/img/avatars/u5.webp'); return asset('assets/img/avatars/avatar-default.svg');
} }
public function getFeaturedImageUrlAttribute() public function getFeaturedImageUrlAttribute()
+19 -2
View File
@@ -45,14 +45,14 @@ trait HasTranslations
// 1. Try translations table // 1. Try translations table
$translation = $this->getTranslation($fieldName, $languageCode); $translation = $this->getTranslation($fieldName, $languageCode);
if ($translation && !empty($translation->field_value)) { if ($translation && $this->hasValidTranslationContent($translation->field_value)) {
return $translation->field_value; return $translation->field_value;
} }
// 2. Fallback to default language from translations table // 2. Fallback to default language from translations table
if ($fallback && $languageCode !== $this->getDefaultLanguageCode()) { if ($fallback && $languageCode !== $this->getDefaultLanguageCode()) {
$defaultTranslation = $this->getTranslation($fieldName, $this->getDefaultLanguageCode()); $defaultTranslation = $this->getTranslation($fieldName, $this->getDefaultLanguageCode());
if ($defaultTranslation && !empty($defaultTranslation->field_value)) { if ($defaultTranslation && $this->hasValidTranslationContent($defaultTranslation->field_value)) {
return $defaultTranslation->field_value; return $defaultTranslation->field_value;
} }
} }
@@ -267,6 +267,23 @@ trait HasTranslations
} }
} }
/**
* Check if translation content has meaningful text or media
*/
protected function hasValidTranslationContent(mixed $value): bool
{
if (empty($value)) {
return false;
}
if (is_string($value)) {
$stripped = trim(strip_tags($value, '<img><iframe><video><audio>'));
return $stripped !== '';
}
return true;
}
/** /**
* Get translatable fields for this model * Get translatable fields for this model
*/ */
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<circle cx="64" cy="64" r="64" fill="#E2E8F0"/>
<circle cx="64" cy="46" r="22" fill="#94A3B8"/>
<path d="M64 74C42 74 24 88 20 106C31.5 119.6 48.7 128 64 128C79.3 128 96.5 119.6 108 106C104 88 86 74 64 74Z" fill="#94A3B8"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

@@ -1815,11 +1815,30 @@
} }
}); });
function updateCharCounter(inputId, counterId, maxLen) {
const input = document.getElementById(inputId);
const counter = document.getElementById(counterId);
if (!input || !counter) return;
const len = input.value.length;
counter.textContent = `${len} / ${maxLen}`;
if (len > maxLen) {
counter.className = 'text-[11px] font-extrabold text-red-600';
} else if (len >= maxLen * 0.85) {
counter.className = 'text-[11px] font-extrabold text-amber-600';
} else {
counter.className = 'text-[11px] font-extrabold text-slate-400';
}
}
function openBlogModal() { function openBlogModal() {
document.getElementById('modal_blog_id').value = ''; document.getElementById('modal_blog_id').value = '';
document.getElementById('modal_blog_title').value = ''; document.getElementById('modal_blog_title').value = '';
document.getElementById('modal_intern_category').value = 'experience'; document.getElementById('modal_intern_category').value = 'experience';
document.getElementById('modal_blog_excerpt').value = ''; document.getElementById('modal_blog_excerpt').value = '';
document.getElementById('modal_meta_title').value = '';
document.getElementById('modal_meta_description').value = '';
updateCharCounter('modal_meta_title', 'modal_meta_title_counter', 60);
updateCharCounter('modal_meta_description', 'modal_meta_description_counter', 160);
if (blogQuill) blogQuill.setContents([]); if (blogQuill) blogQuill.setContents([]);
document.getElementById('blog-modal-title').querySelector('span').textContent = 'Yeni Blog Yazısı Ekle'; document.getElementById('blog-modal-title').querySelector('span').textContent = 'Yeni Blog Yazısı Ekle';
@@ -1837,6 +1856,10 @@
document.getElementById('modal_blog_title').value = blogItem.title; document.getElementById('modal_blog_title').value = blogItem.title;
document.getElementById('modal_intern_category').value = blogItem.intern_category || 'experience'; document.getElementById('modal_intern_category').value = blogItem.intern_category || 'experience';
document.getElementById('modal_blog_excerpt').value = blogItem.excerpt || ''; document.getElementById('modal_blog_excerpt').value = blogItem.excerpt || '';
document.getElementById('modal_meta_title').value = blogItem.meta_title || '';
document.getElementById('modal_meta_description').value = blogItem.meta_description || '';
updateCharCounter('modal_meta_title', 'modal_meta_title_counter', 60);
updateCharCounter('modal_meta_description', 'modal_meta_description_counter', 160);
if (blogQuill && blogItem.content) { if (blogQuill && blogItem.content) {
blogQuill.setContents([]); blogQuill.setContents([]);
blogQuill.clipboard.dangerouslyPasteHTML(blogItem.content); blogQuill.clipboard.dangerouslyPasteHTML(blogItem.content);
@@ -1957,6 +1980,27 @@
<textarea name="excerpt" id="modal_blog_excerpt" rows="2" class="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 text-xs font-medium text-slate-700" placeholder="Yazınızın liste sayfalarında görünecek kısa özeti..."></textarea> <textarea name="excerpt" id="modal_blog_excerpt" rows="2" class="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 text-xs font-medium text-slate-700" placeholder="Yazınızın liste sayfalarında görünecek kısa özeti..."></textarea>
</div> </div>
<!-- SEO Meta Title & Meta Description Fields with Character Counter -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-slate-50/80 rounded-2xl border border-slate-100">
<div>
<div class="flex justify-between items-center mb-1.5">
<label class="block text-xs font-extrabold text-slate-700 uppercase tracking-wider">SEO Meta Başlık</label>
<span id="modal_meta_title_counter" class="text-[11px] font-extrabold text-slate-400">0 / 60</span>
</div>
<input type="text" name="meta_title" id="modal_meta_title" maxlength="60" oninput="updateCharCounter('modal_meta_title', 'modal_meta_title_counter', 60)" class="w-full px-3.5 py-2.5 rounded-xl border border-slate-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 text-xs font-medium text-slate-800 bg-white" placeholder="Max. 60 karakter" />
<p class="text-[10px] text-slate-400 mt-1 font-semibold">Boş bırakılırsa blog başlığı kullanılır.</p>
</div>
<div>
<div class="flex justify-between items-center mb-1.5">
<label class="block text-xs font-extrabold text-slate-700 uppercase tracking-wider">SEO Meta Açıklama</label>
<span id="modal_meta_description_counter" class="text-[11px] font-extrabold text-slate-400">0 / 160</span>
</div>
<textarea name="meta_description" id="modal_meta_description" rows="2" maxlength="160" oninput="updateCharCounter('modal_meta_description', 'modal_meta_description_counter', 160)" class="w-full px-3.5 py-2 rounded-xl border border-slate-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 text-xs font-medium text-slate-700 bg-white" placeholder="Max. 160 karakter"></textarea>
<p class="text-[10px] text-slate-400 mt-1 font-semibold">Boş bırakılırsa kısa özet kullanılır.</p>
</div>
</div>
<div> <div>
<label class="block text-xs font-extrabold text-slate-600 uppercase tracking-wider mb-2">Blog İçeriği *</label> <label class="block text-xs font-extrabold text-slate-600 uppercase tracking-wider mb-2">Blog İçeriği *</label>
<div id="blog-quill-editor-container" class="rounded-xl border border-slate-200 overflow-hidden"> <div id="blog-quill-editor-container" class="rounded-xl border border-slate-200 overflow-hidden">