diff --git a/.cursorrules b/.cursorrules index cb20757..2c586b7 100644 --- a/.cursorrules +++ b/.cursorrules @@ -50,8 +50,87 @@ php artisan make:filament-resource ModelName --generate --model --migration --fa - Table column'ları için header'ları localization'dan al - Validation mesajları için localization kullan -### Dil Dosyası Yapısı +### Filament 4.x Import Kuralları (ÖNEMLİ!) + +#### **Table Actions (DOĞRU):** ```php +// Table Actions için +use Filament\Actions\EditAction; +use Filament\Actions\DeleteAction; +use Filament\Actions\RestoreAction; +use Filament\Actions\ForceDeleteAction; + +// Bulk Actions için +use Filament\Actions\BulkActionGroup; +use Filament\Actions\DeleteBulkAction; +use Filament\Actions\ForceDeleteBulkAction; +use Filament\Actions\RestoreBulkAction; + +// Filters için +use Filament\Tables\Filters\TrashedFilter; +use Filament\Tables\Filters\SelectFilter; + +// Columns için +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\ImageColumn; + +// Table metodları +$table + ->recordActions([ // ✅ Doğru + EditAction::make(), + ]) + ->toolbarActions([ // ✅ Doğru + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); +``` + +#### **Form & Schema Components (DOĞRU):** +```php +// Form input components +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\Toggle; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\DateTimePicker; +use Filament\Forms\Components\FileUpload; + +// Layout components (ÖNEMLİ: Schemas namespace!) +use Filament\Schemas\Components\Section; // ✅ Doğru +use Filament\Schemas\Components\Grid; // ✅ Doğru +use Filament\Schemas\Components\Fieldset; // ✅ Doğru + +// Tabs components (ÖNEMLİ: Forms namespace!) +use Filament\Forms\Components\Tabs; // ✅ Doğru +// Tab kullanımı: Tabs\Tab::make() şeklinde kullan +// Import gerekmez, Tabs namespace'i üzerinden erişilir +``` + +#### **YANLIŞ KULLANIM (Eski Filament 3.x):** +```php +// ❌ YANLIŞ - Bu namespace'ler Filament 4'te YOK! +use Filament\Tables\Actions\EditAction; // ❌ +use Filament\Forms\Components\Section; // ❌ +use Filament\Forms\Components\Grid; // ❌ + +// ❌ YANLIŞ - Eski metod adları +$table + ->actions([...]) // ❌ Kullanma! + ->bulkActions([...]) // ❌ Kullanma! +``` + +#### **ÖZET:** +- ✅ **Table Actions:** `Filament\Actions\` namespace +- ✅ **Table Columns:** `Filament\Tables\Columns\` namespace +- ✅ **Table Filters:** `Filament\Tables\Filters\` namespace +- ✅ **Form Inputs:** `Filament\Forms\Components\` namespace +- ✅ **Layout Components:** `Filament\Schemas\Components\` namespace (Section, Grid, Tabs) +- ✅ **Table Methods:** `->recordActions()` ve `->toolbarActions()` + +### Dil Dosyası Yapısı 'Bu alan zorunludur.', 'field_unique' => 'Bu değer zaten kullanılıyor.', ]; -``` + ### Model Kuralları - `$fillable` array'ini tanımla @@ -147,3 +226,68 @@ Pages modülü bu kuralların uygulandığı örnek modüldür. Yeni modül olu - ✅ Action buttons - ✅ Notifications - ✅ Redirect handling + +## Universal Translation System + +### Translation System Kullanımı +Platform, dinamik ve evrensel bir çeviri sistemi içerir. Herhangi bir modele çeviri desteği eklemek için: + +```php +use App\Traits\HasTranslations; + +class YourModel extends Model +{ + use HasTranslations; + + protected $translatable = [ + 'title', + 'content', + 'description', + 'meta_title', + 'meta_description', + ]; +} +``` + +### Translation Kuralları +- ✅ Çevrilecek alanlar `$translatable` array'inde tanımlanmalı +- ✅ `HasTranslations` trait'i kullanılmalı +- ✅ Trait'te `$translatable` property tanımlama (conflict oluşur) +- ✅ Her model kendi `$translatable` array'ini tanımlar +- ✅ Slug, ID, FK gibi alanlar çevrilmemeli + +### Translation Metodları +```php +// Çeviri kaydet +$model->setTranslation('field_name', 'en', 'Translation', 'published'); + +// Çeviri oku +$model->translate('field_name'); // Mevcut dilde +$model->translate('field_name', 'en'); // Belirli dilde +$model->translate('field_name', 'en', false); // Fallback olmadan + +// Çeviri kontrolü +$model->hasTranslation('field_name', 'en'); + +// İlerleme takibi +$model->getTranslationProgress(); // ['tr' => 100, 'en' => 75] +$model->getMissingTranslations('en'); // ['meta_description'] +``` + +### Translation Workflow +- `draft` → `review` → `approved` → `published` +- Sadece `published` çeviriler son kullanıcıya görünür +- User-language permissions ile kontrol edilir + +### Helper Functions +```php +current_language(); // Mevcut dil objesi +available_languages(); // Aktif diller +translate_model($model, 'field', 'en'); // Model çevirisi +user_can_manage_language('en'); // Kullanıcı yetkisi +switch_language('en'); // Dil değiştir +``` + +### Dokümantasyon +- `docs/TRANSLATION_SYSTEM.md` - Tam sistem dokümantasyonu +- `docs/TRANSLATION_QUICK_START.md` - Hızlı başlangıç rehberi diff --git a/app/Filament/Admin/Resources/Blogs/Pages/CreateBlog.php b/app/Filament/Admin/Resources/Blogs/Pages/CreateBlog.php index fbd5dad..b0c3f01 100644 --- a/app/Filament/Admin/Resources/Blogs/Pages/CreateBlog.php +++ b/app/Filament/Admin/Resources/Blogs/Pages/CreateBlog.php @@ -3,6 +3,7 @@ namespace App\Filament\Admin\Resources\Blogs\Pages; use App\Filament\Admin\Resources\Blogs\BlogResource; +use App\Filament\Admin\Resources\Components\TranslationTabs; use Filament\Resources\Pages\CreateRecord; class CreateBlog extends CreateRecord @@ -23,4 +24,10 @@ class CreateBlog extends CreateRecord { return __('blog.created_successfully'); } + + protected function afterCreate(): void + { + // Save translations + TranslationTabs::saveTranslations($this->record, $this->form->getState()); + } } diff --git a/app/Filament/Admin/Resources/Blogs/Pages/EditBlog.php b/app/Filament/Admin/Resources/Blogs/Pages/EditBlog.php index af1a630..495d912 100644 --- a/app/Filament/Admin/Resources/Blogs/Pages/EditBlog.php +++ b/app/Filament/Admin/Resources/Blogs/Pages/EditBlog.php @@ -3,6 +3,7 @@ namespace App\Filament\Admin\Resources\Blogs\Pages; use App\Filament\Admin\Resources\Blogs\BlogResource; +use App\Filament\Admin\Resources\Components\TranslationTabs; use Filament\Actions\DeleteAction; use Filament\Actions\ForceDeleteAction; use Filament\Actions\RestoreAction; @@ -38,4 +39,28 @@ class EditBlog extends EditRecord { return __('blog.updated_successfully'); } + + protected function mutateFormDataBeforeFill(array $data): array + { + // Load existing translations + $translationData = TranslationTabs::fillFromRecord($this->record); + + \Log::info('Loading translations for edit', [ + 'blog_id' => $this->record->id, + 'translations' => $translationData + ]); + + return array_merge($data, $translationData); + } + + protected function afterSave(): void + { + \Log::info('Saving blog translations', [ + 'blog_id' => $this->record->id, + 'form_state' => $this->form->getState() + ]); + + // Save translations + TranslationTabs::saveTranslations($this->record, $this->form->getState()); + } } diff --git a/app/Filament/Admin/Resources/Blogs/Schemas/BlogForm.php b/app/Filament/Admin/Resources/Blogs/Schemas/BlogForm.php index 13c0a6c..15347c5 100644 --- a/app/Filament/Admin/Resources/Blogs/Schemas/BlogForm.php +++ b/app/Filament/Admin/Resources/Blogs/Schemas/BlogForm.php @@ -2,6 +2,7 @@ namespace App\Filament\Admin\Resources\Blogs\Schemas; +use App\Filament\Admin\Resources\Components\TranslationTabs; use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\FileUpload; @@ -149,6 +150,47 @@ class BlogForm ->columnSpanFull() ->collapsible(true) ->collapsed(true), + + // Çeviri Sekmesi + Section::make('🌍 ' . __('blog.translations_section')) + ->schema([ + TranslationTabs::make([ + 'title' => [ + 'type' => 'text', + 'label' => __('blog.title_field'), + 'required' => false, + 'maxLength' => 255, + ], + 'content' => [ + 'type' => 'richtext', + 'label' => __('blog.content_field'), + 'required' => false, + ], + 'excerpt' => [ + 'type' => 'textarea', + 'label' => __('blog.excerpt_field'), + 'required' => false, + 'maxLength' => 500, + 'rows' => 3, + ], + 'meta_title' => [ + 'type' => 'text', + 'label' => __('blog.meta_title_field'), + 'required' => false, + 'maxLength' => 60, + ], + 'meta_description' => [ + 'type' => 'textarea', + 'label' => __('blog.meta_description_field'), + 'required' => false, + 'maxLength' => 160, + 'rows' => 3, + ], + ]), + ]) + ->columnSpanFull() + ->collapsible(true) + ->collapsed(false), ]); } } diff --git a/app/Filament/Admin/Resources/Components/TranslationTabs.php b/app/Filament/Admin/Resources/Components/TranslationTabs.php new file mode 100644 index 0000000..b0c61c9 --- /dev/null +++ b/app/Filament/Admin/Resources/Components/TranslationTabs.php @@ -0,0 +1,217 @@ +ordered()->get(); + + if ($languages->isEmpty()) { + return Tabs::make('translations'); + } + + $tabs = []; + + foreach ($languages as $language) { + // User permission kontrolü + $canManage = auth()->user()?->canManageLanguage($language->code) ?? true; + + if (!$canManage) { + continue; + } + + $tabFields = []; + + foreach ($fields as $fieldName => $fieldConfig) { + $fieldType = $fieldConfig['type'] ?? 'text'; + $label = $fieldConfig['label'] ?? ucfirst($fieldName); + $required = $fieldConfig['required'] ?? false; + $maxLength = $fieldConfig['maxLength'] ?? null; + $rows = $fieldConfig['rows'] ?? 3; + + $fieldKey = "translations.{$language->code}.{$fieldName}"; + + switch ($fieldType) { + case 'richtext': + $field = RichEditor::make($fieldKey) + ->label($label) + ->fileAttachmentsDisk('public') + ->fileAttachmentsDirectory('translations') + ->fileAttachmentsVisibility('public') + ->toolbarButtons([ + 'attachFiles', + 'blockquote', + 'bold', + 'bulletList', + 'codeBlock', + 'h2', + 'h3', + 'italic', + 'link', + 'orderedList', + 'redo', + 'strike', + 'underline', + 'undo', + ]) + ->columnSpanFull() + ->required($required); + break; + + case 'textarea': + $field = Textarea::make($fieldKey) + ->label($label) + ->rows($rows) + ->columnSpanFull(); + break; + + case 'text': + default: + $field = TextInput::make($fieldKey) + ->label($label); + break; + } + + if ($required) { + $field->required(); + } + + if ($maxLength) { + $field->maxLength($maxLength); + } + + $tabFields[] = $field; + } + + // Convert country code to flag emoji + $code = strtoupper($language->code); + $flag = ''; + for ($i = 0; $i < strlen($code); $i++) { + $flag .= mb_chr(127397 + ord($code[$i])); + } + + $tabs[] = Tab::make($language->name) + ->label($flag . ' ' . $language->native_name) + ->schema($tabFields) + ->badge(fn ($record) => $record + ? self::getTranslationBadge($record, $language->code) + : null + ) + ->badgeColor(fn ($record) => $record + ? self::getTranslationBadgeColor($record, $language->code) + : 'gray' + ); + } + + return Tabs::make('translations') + ->tabs($tabs) + ->columnSpanFull() + ->persistTabInQueryString(); + } + + protected static function getTranslationBadge($record, string $languageCode): ?string + { + if (!method_exists($record, 'getTranslationProgress')) { + return null; + } + + $progress = $record->getTranslationProgress(); + $percentage = $progress[$languageCode] ?? 0; + + if ($percentage === 100) { + return '✓'; + } elseif ($percentage > 0) { + return $percentage . '%'; + } + + return null; + } + + protected static function getTranslationBadgeColor($record, string $languageCode): string + { + if (!method_exists($record, 'getTranslationProgress')) { + return 'gray'; + } + + $progress = $record->getTranslationProgress(); + $percentage = $progress[$languageCode] ?? 0; + + if ($percentage === 100) { + return 'success'; + } elseif ($percentage >= 50) { + return 'warning'; + } elseif ($percentage > 0) { + return 'danger'; + } + + return 'gray'; + } + + public static function fillFromRecord($record): array + { + $data = ['translations' => []]; + + if (!method_exists($record, 'getTranslatableFields')) { + return $data; + } + + $languages = Language::active()->ordered()->get(); + $fields = $record->getTranslatableFields(); + + foreach ($languages as $language) { + $data['translations'][$language->code] = []; + foreach ($fields as $field) { + $translation = $record->getTranslation($field, $language->code, false); + if ($translation) { + $data['translations'][$language->code][$field] = $translation->field_value; + } + } + } + + return $data; + } + + public static function saveTranslations($record, array $data): void + { + if (!method_exists($record, 'setTranslation')) { + return; + } + + $translations = $data['translations'] ?? []; + + \Log::info('Saving translations', ['data' => $translations]); + + foreach ($translations as $languageCode => $fields) { + if (!is_array($fields)) { + continue; + } + + foreach ($fields as $fieldName => $value) { + // Boş değerleri de kaydet (silme işlemi için) + $record->setTranslation( + $fieldName, + $languageCode, + $value ?? '', + 'published', // Default status + auth()->id() + ); + + \Log::info('Translation saved', [ + 'language' => $languageCode, + 'field' => $fieldName, + 'value' => substr($value ?? '', 0, 50) + ]); + } + } + } +} + diff --git a/app/Filament/Admin/Resources/Languages/LanguageResource.php b/app/Filament/Admin/Resources/Languages/LanguageResource.php new file mode 100644 index 0000000..fb7c4ea --- /dev/null +++ b/app/Filament/Admin/Resources/Languages/LanguageResource.php @@ -0,0 +1,68 @@ +schema(\App\Filament\Admin\Resources\Languages\Schemas\LanguageForm::schema()); + } + + public static function table(Table $table): Table + { + return \App\Filament\Admin\Resources\Languages\Tables\LanguagesTable::configure($table); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListLanguages::route('/'), + 'create' => CreateLanguage::route('/create'), + 'edit' => EditLanguage::route('/{record}/edit'), + ]; + } +} + diff --git a/app/Filament/Admin/Resources/Languages/Pages/CreateLanguage.php b/app/Filament/Admin/Resources/Languages/Pages/CreateLanguage.php new file mode 100644 index 0000000..a651c6c --- /dev/null +++ b/app/Filament/Admin/Resources/Languages/Pages/CreateLanguage.php @@ -0,0 +1,39 @@ +success() + ->title(__('language.created_successfully')) + ->body(__('language.created_successfully_body')); + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } + + protected function mutateFormDataBeforeCreate(array $data): array + { + $data['created_by'] = auth()->id(); + $data['updated_by'] = auth()->id(); + + return $data; + } +} + diff --git a/app/Filament/Admin/Resources/Languages/Pages/EditLanguage.php b/app/Filament/Admin/Resources/Languages/Pages/EditLanguage.php new file mode 100644 index 0000000..91a6a1a --- /dev/null +++ b/app/Filament/Admin/Resources/Languages/Pages/EditLanguage.php @@ -0,0 +1,53 @@ +label(__('language.delete')), + RestoreAction::make() + ->label(__('language.restore')), + ForceDeleteAction::make() + ->label(__('language.force_delete')), + ]; + } + + protected function getSavedNotification(): ?Notification + { + return Notification::make() + ->success() + ->title(__('language.updated_successfully')) + ->body(__('language.updated_successfully_body')); + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } + + protected function mutateFormDataBeforeSave(array $data): array + { + $data['updated_by'] = auth()->id(); + + return $data; + } +} + diff --git a/app/Filament/Admin/Resources/Languages/Pages/ListLanguages.php b/app/Filament/Admin/Resources/Languages/Pages/ListLanguages.php new file mode 100644 index 0000000..23ac100 --- /dev/null +++ b/app/Filament/Admin/Resources/Languages/Pages/ListLanguages.php @@ -0,0 +1,26 @@ +label(__('language.create')), + ]; + } +} + diff --git a/app/Filament/Admin/Resources/Languages/Schemas/LanguageForm.php b/app/Filament/Admin/Resources/Languages/Schemas/LanguageForm.php new file mode 100644 index 0000000..0eecc68 --- /dev/null +++ b/app/Filament/Admin/Resources/Languages/Schemas/LanguageForm.php @@ -0,0 +1,95 @@ +schema([ + Grid::make(2)->schema([ + TextInput::make('code') + ->label(__('language.code')) + ->required() + ->unique(ignoreRecord: true) + ->maxLength(10) + ->placeholder('tr, en, de') + ->helperText(__('language.code_helper')) + ->columnSpan(1), + + TextInput::make('flag') + ->label(__('language.flag')) + ->maxLength(10) + ->placeholder('🇹🇷') + ->helperText(__('language.flag_helper')) + ->columnSpan(1), + ]), + + Grid::make(2)->schema([ + TextInput::make('name') + ->label(__('language.name')) + ->required() + ->maxLength(100) + ->placeholder('Turkish') + ->helperText(__('language.name_helper')) + ->columnSpan(1), + + TextInput::make('native_name') + ->label(__('language.native_name')) + ->required() + ->maxLength(100) + ->placeholder('Türkçe') + ->helperText(__('language.native_name_helper')) + ->columnSpan(1), + ]), + + Grid::make(2)->schema([ + Select::make('direction') + ->label(__('language.direction')) + ->options([ + 'ltr' => __('language.direction_ltr'), + 'rtl' => __('language.direction_rtl'), + ]) + ->default('ltr') + ->required() + ->columnSpan(1), + + TextInput::make('sort_order') + ->label(__('language.sort_order')) + ->numeric() + ->default(0) + ->helperText(__('language.sort_order_helper')) + ->columnSpan(1), + ]), + ]), + + Section::make(__('language.settings')) + ->schema([ + Grid::make(3)->schema([ + Checkbox::make('is_active') + ->label(__('language.is_active')) + ->default(true) + ->helperText(__('language.is_active_helper')) + ->columnSpan(1), + + Checkbox::make('is_default') + ->label(__('language.is_default')) + ->default(false) + ->helperText(__('language.is_default_helper')) + ->columnSpan(1) + ->disabled(fn ($record) => $record && $record->is_default), + ]), + ]), + ]; + } +} + diff --git a/app/Filament/Admin/Resources/Languages/Tables/LanguagesTable.php b/app/Filament/Admin/Resources/Languages/Tables/LanguagesTable.php new file mode 100644 index 0000000..b21de0b --- /dev/null +++ b/app/Filament/Admin/Resources/Languages/Tables/LanguagesTable.php @@ -0,0 +1,113 @@ +columns([ + TextColumn::make('flag') + ->label(__('language.table_flag')) + ->sortable() + ->searchable() + ->formatStateUsing(fn (?string $state): string => + '' . ($state ?? '🌐') . '' + ) + ->html() + ->alignCenter(), + + TextColumn::make('code') + ->label(__('language.table_code')) + ->sortable() + ->searchable() + ->badge() + ->color('info'), + + TextColumn::make('name') + ->label(__('language.table_name')) + ->sortable() + ->searchable() + ->description(fn ($record) => $record->native_name), + + IconColumn::make('is_active') + ->label(__('language.table_is_active')) + ->boolean() + ->sortable(), + + IconColumn::make('is_default') + ->label(__('language.table_is_default')) + ->boolean() + ->sortable(), + + TextColumn::make('direction') + ->label(__('language.table_direction')) + ->badge() + ->color(fn (string $state): string => match ($state) { + 'ltr' => 'success', + 'rtl' => 'warning', + }) + ->sortable(), + + TextColumn::make('sort_order') + ->label(__('language.table_sort_order')) + ->numeric() + ->sortable(), + + TextColumn::make('created_at') + ->label(__('language.table_created_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('updated_at') + ->label(__('language.table_updated_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('deleted_at') + ->label(__('language.table_deleted_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + TrashedFilter::make(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + RestoreAction::make(), + ForceDeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ForceDeleteBulkAction::make(), + RestoreBulkAction::make(), + ]), + ]) + ->defaultSort('sort_order') + ->modifyQueryUsing(fn (Builder $query) => $query->withoutGlobalScopes([ + SoftDeletingScope::class, + ])); + } +} + diff --git a/app/Filament/Admin/Resources/Pages/Pages/CreatePage.php b/app/Filament/Admin/Resources/Pages/Pages/CreatePage.php index 199a18e..d6a37d2 100644 --- a/app/Filament/Admin/Resources/Pages/Pages/CreatePage.php +++ b/app/Filament/Admin/Resources/Pages/Pages/CreatePage.php @@ -2,6 +2,7 @@ namespace App\Filament\Admin\Resources\Pages\Pages; +use App\Filament\Admin\Resources\Components\TranslationTabs; use App\Filament\Admin\Resources\Pages\PageResource; use Filament\Resources\Pages\CreateRecord; @@ -23,4 +24,10 @@ class CreatePage extends CreateRecord { return __('pages.created_successfully'); } + + protected function afterCreate(): void + { + // Save translations + TranslationTabs::saveTranslations($this->record, $this->form->getState()); + } } diff --git a/app/Filament/Admin/Resources/Pages/Pages/EditPage.php b/app/Filament/Admin/Resources/Pages/Pages/EditPage.php index a8cb597..569c930 100644 --- a/app/Filament/Admin/Resources/Pages/Pages/EditPage.php +++ b/app/Filament/Admin/Resources/Pages/Pages/EditPage.php @@ -2,6 +2,7 @@ namespace App\Filament\Admin\Resources\Pages\Pages; +use App\Filament\Admin\Resources\Components\TranslationTabs; use App\Filament\Admin\Resources\Pages\PageResource; use Filament\Actions\DeleteAction; use Filament\Actions\ForceDeleteAction; @@ -38,4 +39,18 @@ class EditPage extends EditRecord { return __('pages.updated_successfully'); } + + protected function mutateFormDataBeforeFill(array $data): array + { + // Load existing translations + $translationData = TranslationTabs::fillFromRecord($this->record); + + return array_merge($data, $translationData); + } + + protected function afterSave(): void + { + // Save translations + TranslationTabs::saveTranslations($this->record, $this->form->getState()); + } } diff --git a/app/Filament/Admin/Resources/Pages/Schemas/PageForm.php b/app/Filament/Admin/Resources/Pages/Schemas/PageForm.php index 342a427..ceebbff 100644 --- a/app/Filament/Admin/Resources/Pages/Schemas/PageForm.php +++ b/app/Filament/Admin/Resources/Pages/Schemas/PageForm.php @@ -2,6 +2,7 @@ namespace App\Filament\Admin\Resources\Pages\Schemas; +use App\Filament\Admin\Resources\Components\TranslationTabs; use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\FileUpload; @@ -162,6 +163,47 @@ class PageForm ->columnSpanFull() ->collapsible(true) ->collapsed(true), + + // Çeviri Sekmesi + Section::make('🌍 ' . __('pages.translations_section')) + ->schema([ + TranslationTabs::make([ + 'title' => [ + 'type' => 'text', + 'label' => __('pages.title_field'), + 'required' => false, + 'maxLength' => 255, + ], + 'content' => [ + 'type' => 'richtext', + 'label' => __('pages.content_field'), + 'required' => false, + ], + 'excerpt' => [ + 'type' => 'textarea', + 'label' => __('pages.excerpt_field'), + 'required' => false, + 'maxLength' => 500, + 'rows' => 3, + ], + 'meta_title' => [ + 'type' => 'text', + 'label' => __('pages.meta_title_field'), + 'required' => false, + 'maxLength' => 60, + ], + 'meta_description' => [ + 'type' => 'textarea', + 'label' => __('pages.meta_description_field'), + 'required' => false, + 'maxLength' => 160, + 'rows' => 3, + ], + ]), + ]) + ->columnSpanFull() + ->collapsible(true) + ->collapsed(false), ]); } } diff --git a/app/Helpers/translation_helpers.php b/app/Helpers/translation_helpers.php new file mode 100644 index 0000000..d71464b --- /dev/null +++ b/app/Helpers/translation_helpers.php @@ -0,0 +1,284 @@ +getLocale()); + } +} + +if (!function_exists('current_language_code')) { + /** + * Get current language code + */ + function current_language_code(): string + { + return app()->getLocale(); + } +} + +if (!function_exists('default_language')) { + /** + * Get default language + */ + function default_language(): ?Language + { + return Language::getDefault(); + } +} + +if (!function_exists('default_language_code')) { + /** + * Get default language code + */ + function default_language_code(): string + { + $defaultLanguage = Language::getDefault(); + return $defaultLanguage ? $defaultLanguage->code : config('app.locale'); + } +} + +if (!function_exists('available_languages')) { + /** + * Get all active languages + */ + function available_languages(): Collection + { + return Language::getActive(); + } +} + +if (!function_exists('available_language_codes')) { + /** + * Get all active language codes + */ + function available_language_codes(): array + { + return Language::active()->pluck('code')->toArray(); + } +} + +if (!function_exists('translate_model')) { + /** + * Get translated value for a model field + * + * @param mixed $model Model instance + * @param string $field Field name to translate + * @param string|null $languageCode Language code (null for current) + * @param bool $fallback Use fallback to default language + * @return mixed + */ + function translate_model($model, string $field, ?string $languageCode = null, bool $fallback = true): mixed + { + if (!method_exists($model, 'translate')) { + return $model->{$field} ?? null; + } + + return $model->translate($field, $languageCode, $fallback); + } +} + +if (!function_exists('has_translation')) { + /** + * Check if model has translation for field and language + */ + function has_translation($model, string $field, ?string $languageCode = null): bool + { + if (!method_exists($model, 'hasTranslation')) { + return false; + } + + $languageCode = $languageCode ?? current_language_code(); + return $model->hasTranslation($field, $languageCode); + } +} + +if (!function_exists('translation_progress')) { + /** + * Get translation progress for a model + */ + function translation_progress($model, ?string $languageCode = null): int + { + if (!method_exists($model, 'getTranslationProgress')) { + return 0; + } + + $progress = $model->getTranslationProgress(); + + if ($languageCode) { + return $progress[$languageCode] ?? 0; + } + + return $progress; + } +} + +if (!function_exists('user_can_manage_language')) { + /** + * Check if current user can manage a language + */ + function user_can_manage_language(string $languageCode): bool + { + $user = auth()->user(); + + if (!$user || !method_exists($user, 'canManageLanguage')) { + return false; + } + + return $user->canManageLanguage($languageCode); + } +} + +if (!function_exists('user_managed_languages')) { + /** + * Get languages that current user can manage + */ + function user_managed_languages(): Collection + { + $user = auth()->user(); + + if (!$user || !method_exists($user, 'getManagedLanguages')) { + return collect(); + } + + return $user->getManagedLanguages(); + } +} + +if (!function_exists('user_managed_language_codes')) { + /** + * Get language codes that current user can manage + */ + function user_managed_language_codes(): array + { + $user = auth()->user(); + + if (!$user || !method_exists($user, 'getManagedLanguageCodes')) { + return []; + } + + return $user->getManagedLanguageCodes(); + } +} + +if (!function_exists('switch_language')) { + /** + * Switch application language + */ + function switch_language(string $languageCode): bool + { + $language = Language::findByCode($languageCode); + + if (!$language || !$language->is_active) { + return false; + } + + app()->setLocale($languageCode); + + if (auth()->check()) { + session(['locale' => $languageCode]); + } + + return true; + } +} + +if (!function_exists('get_translation_status_badge')) { + /** + * Get translation status badge HTML + */ + function get_translation_status_badge(string $status): string + { + $badges = [ + 'draft' => 'Draft', + 'review' => 'Review', + 'approved' => 'Approved', + 'published' => 'Published', + ]; + + return $badges[$status] ?? $badges['draft']; + } +} + +if (!function_exists('get_translation_status_color')) { + /** + * Get translation status color + */ + function get_translation_status_color(string $status): string + { + $colors = [ + 'draft' => 'gray', + 'review' => 'warning', + 'approved' => 'success', + 'published' => 'primary', + ]; + + return $colors[$status] ?? 'gray'; + } +} + +if (!function_exists('get_language_flag')) { + /** + * Get language flag emoji or icon + */ + function get_language_flag(string $languageCode): string + { + $language = Language::findByCode($languageCode); + return $language ? ($language->flag ?? '🌐') : '🌐'; + } +} + +if (!function_exists('format_language_name')) { + /** + * Format language name with native name + */ + function format_language_name(string $languageCode): string + { + $language = Language::findByCode($languageCode); + + if (!$language) { + return $languageCode; + } + + return "{$language->name} ({$language->native_name})"; + } +} + +if (!function_exists('translation_exists')) { + /** + * Check if a translation exists in database + */ + function translation_exists(string $modelClass, int $modelId, string $field, string $languageCode): bool + { + return Translation::forModel($modelClass, $modelId) + ->forField($field) + ->forLanguage($languageCode) + ->exists(); + } +} + +if (!function_exists('get_translation_value')) { + /** + * Get translation value directly from Translation model + */ + function get_translation_value(string $modelClass, int $modelId, string $field, ?string $languageCode = null): ?string + { + $languageCode = $languageCode ?? current_language_code(); + + $translation = Translation::forModel($modelClass, $modelId) + ->forField($field) + ->forLanguage($languageCode) + ->published() + ->first(); + + return $translation ? $translation->field_value : null; + } +} + diff --git a/app/Models/Blog.php b/app/Models/Blog.php index d53d08e..adcc672 100644 --- a/app/Models/Blog.php +++ b/app/Models/Blog.php @@ -2,13 +2,14 @@ namespace App\Models; +use App\Traits\HasTranslations; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; class Blog extends Model { - use HasFactory, SoftDeletes; + use HasFactory, SoftDeletes, HasTranslations; protected $fillable = [ 'title', @@ -28,6 +29,17 @@ class Blog extends Model 'allow_comments', ]; + /** + * Translatable fields + */ + protected $translatable = [ + 'title', + 'content', + 'excerpt', + 'meta_title', + 'meta_description', + ]; + protected $casts = [ 'published_at' => 'datetime', 'tags' => 'array', diff --git a/app/Models/Language.php b/app/Models/Language.php new file mode 100644 index 0000000..ca3fe3a --- /dev/null +++ b/app/Models/Language.php @@ -0,0 +1,124 @@ + 'boolean', + 'is_default' => 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * Boot method + */ + protected static function boot(): void + { + parent::boot(); + + // Only one default language allowed + static::saving(function ($language) { + if ($language->is_default) { + static::where('id', '!=', $language->id)->update(['is_default' => false]); + } + }); + } + + /** + * Relationships + */ + public function translations(): HasMany + { + return $this->hasMany(Translation::class, 'language_code', 'code'); + } + + public function userLanguages(): HasMany + { + return $this->hasMany(UserLanguage::class, 'language_code', 'code'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * Scopes + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeOrdered($query) + { + return $query->orderBy('sort_order')->orderBy('name'); + } + + /** + * Helpers + */ + public static function getDefault(): ?self + { + return static::where('is_default', true)->first(); + } + + public static function getActive(): \Illuminate\Database\Eloquent\Collection + { + return static::active()->ordered()->get(); + } + + public static function findByCode(string $code): ?self + { + return static::where('code', $code)->first(); + } + + public function activate(): bool + { + $this->is_active = true; + return $this->save(); + } + + public function deactivate(): bool + { + if ($this->is_default) { + return false; // Cannot deactivate default language + } + $this->is_active = false; + return $this->save(); + } + + public function setAsDefault(): bool + { + $this->is_default = true; + $this->is_active = true; + return $this->save(); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php index 7823d78..e039d07 100644 --- a/app/Models/Page.php +++ b/app/Models/Page.php @@ -2,13 +2,14 @@ namespace App\Models; +use App\Traits\HasTranslations; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; class Page extends Model { - use HasFactory, SoftDeletes; + use HasFactory, SoftDeletes, HasTranslations; protected $fillable = [ 'title', @@ -28,6 +29,17 @@ class Page extends Model 'show_in_menu', ]; + /** + * Translatable fields + */ + protected $translatable = [ + 'title', + 'content', + 'excerpt', + 'meta_title', + 'meta_description', + ]; + protected $casts = [ 'published_at' => 'datetime', 'is_homepage' => 'boolean', diff --git a/app/Models/Translation.php b/app/Models/Translation.php new file mode 100644 index 0000000..b0f4103 --- /dev/null +++ b/app/Models/Translation.php @@ -0,0 +1,201 @@ + 'integer', + 'approved_at' => 'datetime', + 'published_at' => 'datetime', + ]; + + /** + * Relationships + */ + public function translatable(): MorphTo + { + return $this->morphTo(); + } + + public function language(): BelongsTo + { + return $this->belongsTo(Language::class, 'language_code', 'code'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + /** + * Scopes + */ + public function scopeForModel($query, string $modelClass, int $modelId) + { + return $query->where('translatable_type', $modelClass) + ->where('translatable_id', $modelId); + } + + public function scopeForLanguage($query, string $languageCode) + { + return $query->where('language_code', $languageCode); + } + + public function scopeForField($query, string $fieldName) + { + return $query->where('field_name', $fieldName); + } + + public function scopePublished($query) + { + return $query->where('status', 'published'); + } + + public function scopeApproved($query) + { + return $query->whereIn('status', ['approved', 'published']); + } + + public function scopeDraft($query) + { + return $query->where('status', 'draft'); + } + + public function scopeReview($query) + { + return $query->where('status', 'review'); + } + + public function scopePendingApproval($query) + { + return $query->whereIn('status', ['draft', 'review']); + } + + /** + * Workflow Methods + */ + public function submitForReview(): bool + { + if ($this->status === 'draft') { + $this->status = 'review'; + return $this->save(); + } + return false; + } + + public function approve(?int $userId = null): bool + { + if (in_array($this->status, ['draft', 'review'])) { + $this->status = 'approved'; + $this->approved_by = $userId ?? auth()->id(); + $this->approved_at = now(); + return $this->save(); + } + return false; + } + + public function publish(?int $userId = null): bool + { + if (in_array($this->status, ['approved', 'draft', 'review'])) { + $this->status = 'published'; + $this->published_at = now(); + if (!$this->approved_by) { + $this->approved_by = $userId ?? auth()->id(); + $this->approved_at = now(); + } + return $this->save(); + } + return false; + } + + public function reject(): bool + { + if ($this->status === 'review') { + $this->status = 'draft'; + return $this->save(); + } + return false; + } + + public function unpublish(): bool + { + if ($this->status === 'published') { + $this->status = 'approved'; + $this->published_at = null; + return $this->save(); + } + return false; + } + + /** + * Helper Methods + */ + public function isDraft(): bool + { + return $this->status === 'draft'; + } + + public function isReview(): bool + { + return $this->status === 'review'; + } + + public function isApproved(): bool + { + return $this->status === 'approved'; + } + + public function isPublished(): bool + { + return $this->status === 'published'; + } + + public function canBeEdited(): bool + { + return in_array($this->status, ['draft', 'review']); + } + + public function canBeApproved(): bool + { + return in_array($this->status, ['draft', 'review']); + } + + public function canBePublished(): bool + { + return in_array($this->status, ['draft', 'review', 'approved']); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index ac2ca92..952ef3a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Spatie\Permission\Traits\HasRoles; @@ -46,4 +47,128 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + /** + * Relationships + */ + public function userLanguages(): HasMany + { + return $this->hasMany(UserLanguage::class); + } + + public function languages() + { + return $this->belongsToMany(Language::class, 'user_languages', 'user_id', 'language_code', 'id', 'code') + ->withPivot(['can_create', 'can_edit', 'can_approve', 'can_publish']) + ->withTimestamps(); + } + + /** + * Language Management Methods + */ + public function canManageLanguage(string $languageCode): bool + { + // Super admin can manage all languages + if ($this->hasRole('super_admin')) { + return true; + } + + return $this->userLanguages() + ->where('language_code', $languageCode) + ->where(function ($query) { + $query->where('can_create', true) + ->orWhere('can_edit', true) + ->orWhere('can_approve', true) + ->orWhere('can_publish', true); + }) + ->exists(); + } + + public function canCreateInLanguage(string $languageCode): bool + { + if ($this->hasRole('super_admin')) { + return true; + } + + return $this->userLanguages() + ->where('language_code', $languageCode) + ->where('can_create', true) + ->exists(); + } + + public function canEditInLanguage(string $languageCode): bool + { + if ($this->hasRole('super_admin')) { + return true; + } + + return $this->userLanguages() + ->where('language_code', $languageCode) + ->where('can_edit', true) + ->exists(); + } + + public function canApproveInLanguage(string $languageCode): bool + { + if ($this->hasRole('super_admin')) { + return true; + } + + return $this->userLanguages() + ->where('language_code', $languageCode) + ->where('can_approve', true) + ->exists(); + } + + public function canPublishInLanguage(string $languageCode): bool + { + if ($this->hasRole('super_admin')) { + return true; + } + + return $this->userLanguages() + ->where('language_code', $languageCode) + ->where('can_publish', true) + ->exists(); + } + + public function getManagedLanguages(): \Illuminate\Support\Collection + { + return $this->userLanguages() + ->with('language') + ->get() + ->pluck('language'); + } + + public function getManagedLanguageCodes(): array + { + return $this->userLanguages() + ->pluck('language_code') + ->toArray(); + } + + public function assignLanguage( + string $languageCode, + bool $canCreate = true, + bool $canEdit = true, + bool $canApprove = false, + bool $canPublish = false + ): UserLanguage { + return $this->userLanguages()->updateOrCreate( + ['language_code' => $languageCode], + [ + 'can_create' => $canCreate, + 'can_edit' => $canEdit, + 'can_approve' => $canApprove, + 'can_publish' => $canPublish, + ] + ); + } + + public function removeLanguage(string $languageCode): bool + { + return $this->userLanguages() + ->where('language_code', $languageCode) + ->delete() > 0; + } } diff --git a/app/Models/UserLanguage.php b/app/Models/UserLanguage.php new file mode 100644 index 0000000..99701e1 --- /dev/null +++ b/app/Models/UserLanguage.php @@ -0,0 +1,105 @@ + 'boolean', + 'can_edit' => 'boolean', + 'can_approve' => 'boolean', + 'can_publish' => 'boolean', + ]; + + /** + * Relationships + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function language(): BelongsTo + { + return $this->belongsTo(Language::class, 'language_code', 'code'); + } + + /** + * Scopes + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + public function scopeForLanguage($query, string $languageCode) + { + return $query->where('language_code', $languageCode); + } + + public function scopeCanCreate($query) + { + return $query->where('can_create', true); + } + + public function scopeCanEdit($query) + { + return $query->where('can_edit', true); + } + + public function scopeCanApprove($query) + { + return $query->where('can_approve', true); + } + + public function scopeCanPublish($query) + { + return $query->where('can_publish', true); + } + + /** + * Helper Methods + */ + public function hasFullAccess(): bool + { + return $this->can_create && $this->can_edit && $this->can_approve && $this->can_publish; + } + + public function hasNoAccess(): bool + { + return !$this->can_create && !$this->can_edit && !$this->can_approve && !$this->can_publish; + } + + public function grantFullAccess(): bool + { + $this->can_create = true; + $this->can_edit = true; + $this->can_approve = true; + $this->can_publish = true; + return $this->save(); + } + + public function revokeAllAccess(): bool + { + $this->can_create = false; + $this->can_edit = false; + $this->can_approve = false; + $this->can_publish = false; + return $this->save(); + } +} diff --git a/app/Policies/LanguagePolicy.php b/app/Policies/LanguagePolicy.php new file mode 100644 index 0000000..95f50ff --- /dev/null +++ b/app/Policies/LanguagePolicy.php @@ -0,0 +1,97 @@ +hasRole('super_admin') || $user->can('language::viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Language $language): bool + { + return $user->hasRole('super_admin') || $user->can('language::view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasRole('super_admin') || $user->can('language::create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Language $language): bool + { + // Cannot disable default language + if ($language->is_default && !$user->hasRole('super_admin')) { + return false; + } + + return $user->hasRole('super_admin') || $user->can('language::update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Language $language): bool + { + // Cannot delete default language + if ($language->is_default) { + return false; + } + + return $user->hasRole('super_admin') || $user->can('language::delete'); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Language $language): bool + { + return $user->hasRole('super_admin') || $user->can('language::restore'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Language $language): bool + { + // Cannot force delete default language + if ($language->is_default) { + return false; + } + + return $user->hasRole('super_admin') || $user->can('language::forceDelete'); + } + + /** + * Determine whether the user can activate the language. + */ + public function activate(User $user): bool + { + return $user->hasRole('super_admin') || $user->can('language::activate'); + } + + /** + * Determine whether the user can set the language as default. + */ + public function setDefault(User $user): bool + { + return $user->hasRole('super_admin') || $user->can('language::setDefault'); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 89c03aa..07a76d3 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -2,7 +2,9 @@ namespace App\Providers; +use App\Models\Language; use App\Models\User; +use App\Policies\LanguagePolicy; use App\Policies\UserPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Spatie\Permission\Models\Role; @@ -18,6 +20,7 @@ class AuthServiceProvider extends ServiceProvider protected $policies = [ User::class => UserPolicy::class, Role::class => RolePolicy::class, + Language::class => LanguagePolicy::class, ]; /** diff --git a/app/Traits/HasTranslations.php b/app/Traits/HasTranslations.php new file mode 100644 index 0000000..0c97b24 --- /dev/null +++ b/app/Traits/HasTranslations.php @@ -0,0 +1,309 @@ +morphMany(Translation::class, 'translatable'); + } + + /** + * Get translation for a specific field and language + */ + public function getTranslation(string $fieldName, ?string $languageCode = null, bool $published = true): ?Translation + { + $languageCode = $languageCode ?? $this->getCurrentLanguageCode(); + + $query = $this->translations() + ->where('field_name', $fieldName) + ->where('language_code', $languageCode); + + if ($published) { + $query->where('status', 'published'); + } + + return $query->first(); + } + + /** + * Get translation value for a specific field and language + */ + public function translate(string $fieldName, ?string $languageCode = null, bool $fallback = true): mixed + { + $languageCode = $languageCode ?? $this->getCurrentLanguageCode(); + + $translation = $this->getTranslation($fieldName, $languageCode); + + if ($translation) { + return $translation->field_value; + } + + // Fallback to default language + if ($fallback && $languageCode !== $this->getDefaultLanguageCode()) { + $defaultTranslation = $this->getTranslation($fieldName, $this->getDefaultLanguageCode()); + if ($defaultTranslation) { + return $defaultTranslation->field_value; + } + } + + // Fallback to original field value + return $this->{$fieldName} ?? null; + } + + /** + * Set translation for a specific field and language + */ + public function setTranslation( + string $fieldName, + string $languageCode, + mixed $value, + string $status = 'draft', + ?int $userId = null + ): Translation { + $userId = $userId ?? auth()->id(); + + return $this->translations()->updateOrCreate( + [ + 'field_name' => $fieldName, + 'language_code' => $languageCode, + ], + [ + 'field_value' => $value, + 'status' => $status, + 'created_by' => $userId, + 'updated_by' => $userId, + ] + ); + } + + /** + * Get all translations for a specific field + */ + public function getTranslations(string $fieldName, bool $published = true): Collection + { + $query = $this->translations() + ->where('field_name', $fieldName); + + if ($published) { + $query->where('status', 'published'); + } + + return $query->get()->keyBy('language_code'); + } + + /** + * Get all translations for a specific language + */ + public function getTranslationsForLanguage(string $languageCode, bool $published = true): Collection + { + $query = $this->translations() + ->where('language_code', $languageCode); + + if ($published) { + $query->where('status', 'published'); + } + + return $query->get()->keyBy('field_name'); + } + + /** + * Check if translation exists for a field and language + */ + public function hasTranslation(string $fieldName, string $languageCode, bool $published = true): bool + { + $query = $this->translations() + ->where('field_name', $fieldName) + ->where('language_code', $languageCode); + + if ($published) { + $query->where('status', 'published'); + } + + return $query->exists(); + } + + /** + * Get translation progress for all languages + */ + public function getTranslationProgress(): array + { + $languages = Language::active()->get(); + $translatableFields = $this->getTranslatableFields(); + $totalFields = count($translatableFields); + + $progress = []; + + foreach ($languages as $language) { + $translatedCount = $this->translations() + ->where('language_code', $language->code) + ->where('status', 'published') + ->whereIn('field_name', $translatableFields) + ->count(); + + $progress[$language->code] = $totalFields > 0 + ? round(($translatedCount / $totalFields) * 100) + : 0; + } + + return $progress; + } + + /** + * Get missing translations for a specific language + */ + public function getMissingTranslations(string $languageCode): array + { + $translatableFields = $this->getTranslatableFields(); + + $existingTranslations = $this->translations() + ->where('language_code', $languageCode) + ->where('status', 'published') + ->pluck('field_name') + ->toArray(); + + return array_diff($translatableFields, $existingTranslations); + } + + /** + * Duplicate translation from one language to another + */ + public function duplicateTranslation(string $fromLanguage, string $toLanguage, ?int $userId = null): int + { + $userId = $userId ?? auth()->id(); + $count = 0; + + $sourceTranslations = $this->getTranslationsForLanguage($fromLanguage, false); + + foreach ($sourceTranslations as $fieldName => $translation) { + $this->setTranslation( + $fieldName, + $toLanguage, + $translation->field_value, + 'draft', + $userId + ); + $count++; + } + + return $count; + } + + /** + * Get translation status for a language + */ + public function getTranslationStatus(string $languageCode): string + { + $progress = $this->getTranslationProgress(); + $percentage = $progress[$languageCode] ?? 0; + + if ($percentage === 100) { + return 'complete'; + } elseif ($percentage > 0) { + return 'partial'; + } else { + return 'missing'; + } + } + + /** + * Sync translations for all translatable fields + */ + public function syncTranslations(array $languageCodes, ?int $userId = null): void + { + $userId = $userId ?? auth()->id(); + $translatableFields = $this->getTranslatableFields(); + + foreach ($languageCodes as $languageCode) { + foreach ($translatableFields as $field) { + if (!$this->hasTranslation($field, $languageCode, false)) { + $this->setTranslation( + $field, + $languageCode, + $this->{$field} ?? '', + 'draft', + $userId + ); + } + } + } + } + + /** + * Get translatable fields for this model + */ + public function getTranslatableFields(): array + { + return $this->translatable ?? []; + } + + /** + * Get current language code + */ + protected function getCurrentLanguageCode(): string + { + return app()->getLocale(); + } + + /** + * Get default language code + */ + protected function getDefaultLanguageCode(): string + { + $defaultLanguage = Language::where('is_default', true)->first(); + return $defaultLanguage ? $defaultLanguage->code : config('app.locale'); + } + + /** + * Scope: Filter by language + */ + public function scopeWhereHasTranslation($query, string $languageCode, bool $published = true) + { + return $query->whereHas('translations', function ($q) use ($languageCode, $published) { + $q->where('language_code', $languageCode); + if ($published) { + $q->where('status', 'published'); + } + }); + } + + /** + * Scope: Filter by user's languages + */ + public function scopeWhereHasTranslationForUser($query, $user) + { + $languageCodes = $user->userLanguages()->pluck('language_code')->toArray(); + + return $query->whereHas('translations', function ($q) use ($languageCodes) { + $q->whereIn('language_code', $languageCodes); + }); + } + + /** + * Get translated model for current language + */ + public function translated(?string $languageCode = null): self + { + $languageCode = $languageCode ?? $this->getCurrentLanguageCode(); + + $translations = $this->getTranslationsForLanguage($languageCode); + + foreach ($translations as $fieldName => $translation) { + if (in_array($fieldName, $this->getTranslatableFields())) { + $this->{$fieldName} = $translation->field_value; + } + } + + return $this; + } +} + diff --git a/composer.json b/composer.json index d6aa7cd..b3ff955 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,10 @@ "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" - } + }, + "files": [ + "app/Helpers/translation_helpers.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/database/migrations/2025_10_01_053644_create_languages_table.php b/database/migrations/2025_10_01_053644_create_languages_table.php new file mode 100644 index 0000000..ac59216 --- /dev/null +++ b/database/migrations/2025_10_01_053644_create_languages_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('code', 10)->unique()->comment('Language code (tr, en, de)'); + $table->string('name', 100)->comment('Language name in English'); + $table->string('native_name', 100)->comment('Language name in native language'); + $table->string('flag', 10)->nullable()->comment('Flag emoji or icon code'); + $table->enum('direction', ['ltr', 'rtl'])->default('ltr')->comment('Text direction'); + $table->boolean('is_active')->default(true)->comment('Is language active?'); + $table->boolean('is_default')->default(false)->comment('Is default language?'); + $table->integer('sort_order')->default(0)->comment('Display order'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['is_active', 'sort_order']); + $table->index('code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('languages'); + } +}; diff --git a/database/migrations/2025_10_01_053653_create_translations_table.php b/database/migrations/2025_10_01_053653_create_translations_table.php new file mode 100644 index 0000000..cd6d241 --- /dev/null +++ b/database/migrations/2025_10_01_053653_create_translations_table.php @@ -0,0 +1,52 @@ +id(); + $table->string('translatable_type')->comment('Model class name'); + $table->unsignedBigInteger('translatable_id')->comment('Model ID'); + $table->string('language_code', 10)->comment('Language code'); + $table->string('field_name', 100)->comment('Field name to translate'); + $table->longText('field_value')->nullable()->comment('Translated value'); + $table->enum('status', ['draft', 'review', 'approved', 'published'])->default('draft')->comment('Translation status'); + $table->integer('version')->default(1)->comment('Translation version'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + // Indexes for performance + $table->index(['translatable_type', 'translatable_id']); + $table->index(['language_code', 'status']); + $table->index(['created_by', 'status']); + $table->index('approved_by'); + + // Unique constraint: one translation per model+field+language + $table->unique(['translatable_type', 'translatable_id', 'language_code', 'field_name'], 'unique_translation'); + + // Foreign key for language + $table->foreign('language_code')->references('code')->on('languages')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('translations'); + } +}; diff --git a/database/migrations/2025_10_01_053657_create_user_languages_table.php b/database/migrations/2025_10_01_053657_create_user_languages_table.php new file mode 100644 index 0000000..6c2e89c --- /dev/null +++ b/database/migrations/2025_10_01_053657_create_user_languages_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->string('language_code', 10); + $table->boolean('can_create')->default(true)->comment('Can create content in this language'); + $table->boolean('can_edit')->default(true)->comment('Can edit content in this language'); + $table->boolean('can_approve')->default(false)->comment('Can approve translations in this language'); + $table->boolean('can_publish')->default(false)->comment('Can publish content in this language'); + $table->timestamps(); + + // Unique constraint: one entry per user+language + $table->unique(['user_id', 'language_code']); + + // Foreign key for language + $table->foreign('language_code')->references('code')->on('languages')->onDelete('cascade'); + + // Indexes + $table->index(['user_id', 'language_code']); + $table->index('language_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_languages'); + } +}; diff --git a/database/seeders/LanguageSeeder.php b/database/seeders/LanguageSeeder.php new file mode 100644 index 0000000..24c9b61 --- /dev/null +++ b/database/seeders/LanguageSeeder.php @@ -0,0 +1,78 @@ + 'tr', + 'name' => 'Turkish', + 'native_name' => 'Türkçe', + 'flag' => '🇹🇷', + 'direction' => 'ltr', + 'is_active' => true, + 'is_default' => true, + 'sort_order' => 1, + ], + [ + 'code' => 'en', + 'name' => 'English', + 'native_name' => 'English', + 'flag' => '🇬🇧', + 'direction' => 'ltr', + 'is_active' => true, + 'is_default' => false, + 'sort_order' => 2, + ], + [ + 'code' => 'de', + 'name' => 'German', + 'native_name' => 'Deutsch', + 'flag' => '🇩🇪', + 'direction' => 'ltr', + 'is_active' => false, + 'is_default' => false, + 'sort_order' => 3, + ], + [ + 'code' => 'fr', + 'name' => 'French', + 'native_name' => 'Français', + 'flag' => '🇫🇷', + 'direction' => 'ltr', + 'is_active' => false, + 'is_default' => false, + 'sort_order' => 4, + ], + [ + 'code' => 'ar', + 'name' => 'Arabic', + 'native_name' => 'العربية', + 'flag' => '🇸🇦', + 'direction' => 'rtl', + 'is_active' => false, + 'is_default' => false, + 'sort_order' => 5, + ], + ]; + + foreach ($languages as $language) { + Language::updateOrCreate( + ['code' => $language['code']], + $language + ); + } + + $this->command->info('Languages seeded successfully!'); + } +} diff --git a/docs/TRANSLATION_QUICK_START.md b/docs/TRANSLATION_QUICK_START.md new file mode 100644 index 0000000..941904d --- /dev/null +++ b/docs/TRANSLATION_QUICK_START.md @@ -0,0 +1,352 @@ +# Translation System - Quick Start Guide + +## 🚀 5 Dakikada Başlangıç + +### 1️⃣ Admin Panel'e Git + +``` +http://your-domain.com/admin/languages +``` + +### 2️⃣ Dilleri Aktifleştir + +- 🇹🇷 Türkçe (TR) - Zaten aktif (varsayılan) +- 🇬🇧 İngilizce (EN) - Zaten aktif +- 🇩🇪 Almanca (DE) - İsterseniz aktif edin +- 🇫🇷 Fransızca (FR) - İsterseniz aktif edin + +### 3️⃣ İlk Çeviriyi Ekle + +#### Tinker ile (Terminal): +```bash +php artisan tinker + +# Blog çevirisi ekle +$blog = App\Models\Blog::first(); +$blog->setTranslation('title', 'en', 'My First Blog Post', 'published'); +$blog->setTranslation('content', 'en', 'This is my blog content in English...', 'published'); + +# Kontrol et +echo $blog->translate('title', 'en'); +``` + +#### Kod ile: +```php +use App\Models\Blog; + +$blog = Blog::find(1); + +// İngilizce çeviri ekle +$blog->setTranslation('title', 'en', 'My Blog Title', 'published'); +$blog->setTranslation('content', 'en', 'Blog content here...', 'published'); +$blog->setTranslation('excerpt', 'en', 'Short description', 'published'); + +// Oku +echo $blog->translate('title'); // Mevcut dilde +echo $blog->translate('title', 'en'); // İngilizce +echo $blog->translate('title', 'tr'); // Türkçe +``` + +### 4️⃣ Translation Progress Kontrol Et + +```php +$blog = Blog::find(1); + +// İlerleme yüzdeleri +$progress = $blog->getTranslationProgress(); +print_r($progress); +// ['tr' => 100, 'en' => 60, 'de' => 0] + +// Eksik çeviriler +$missing = $blog->getMissingTranslations('en'); +print_r($missing); +// ['meta_title', 'meta_description'] + +// Durum +echo $blog->getTranslationStatus('en'); // 'partial' +``` + +### 5️⃣ Kullanıcıya Dil Yetkisi Ver + +```php +use App\Models\User; + +$user = User::find(1); + +// Türkçe içerik yöneticisi +$user->assignLanguage('tr', + canCreate: true, + canEdit: true, + canApprove: true, + canPublish: true +); + +// İngilizce çevirmen (yayınlayamaz) +$user->assignLanguage('en', + canCreate: true, + canEdit: true, + canApprove: false, + canPublish: false +); + +// Kontrol +if ($user->canManageLanguage('tr')) { + echo "User can manage Turkish!"; +} +``` + +## 💡 Yaygın Kullanım Örnekleri + +### Örnek 1: Blog Oluştur ve Çevir + +```php +// 1. Türkçe blog oluştur +$blog = Blog::create([ + 'title' => 'Laravel Eğitimi', + 'slug' => 'laravel-egitimi', + 'content' => 'Laravel hakkında detaylı bilgi...', + 'excerpt' => 'Laravel nedir?', + 'status' => 'published', + 'author_id' => 1, +]); + +// 2. İngilizce çevirisi ekle +$blog->setTranslation('title', 'en', 'Laravel Tutorial', 'published'); +$blog->setTranslation('content', 'en', 'Detailed info about Laravel...', 'published'); +$blog->setTranslation('excerpt', 'en', 'What is Laravel?', 'published'); + +// 3. Almanca çevirisi ekle (draft olarak) +$blog->setTranslation('title', 'de', 'Laravel Tutorial', 'draft'); +$blog->setTranslation('content', 'de', 'Detaillierte Informationen über Laravel...', 'draft'); + +// 4. Çevirileri kullan +echo $blog->translate('title'); // Mevcut dilde +echo $blog->translate('title', 'en'); // İngilizce +echo $blog->translate('title', 'de'); // Almanca (draft, yayında değil) +``` + +### Örnek 2: Translation Workflow + +```php +$blog = Blog::find(1); + +// Çeviri oluştur (draft olarak başlar) +$translation = $blog->setTranslation('title', 'en', 'My Title', 'draft'); + +// Workflow adımları +$translation->submitForReview(); // Draft → Review +$translation->approve(auth()->id()); // Review → Approved +$translation->publish(auth()->id()); // Approved → Published + +// Veya direkt yayınla +$translation->publish(); // Draft/Review/Approved → Published + +// Geri al +$translation->unpublish(); // Published → Approved +$translation->reject(); // Review → Draft +``` + +### Örnek 3: Yeni Model Ekle + +```php +// 1. Model oluştur +class Product extends Model +{ + use HasTranslations; + + protected $fillable = [ + 'name', 'slug', 'description', 'price', 'sku' + ]; + + protected $translatable = [ + 'name', + 'description', + ]; +} + +// 2. Migration +Schema::create('products', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description'); + $table->decimal('price', 10, 2); + $table->string('sku')->unique(); + $table->timestamps(); +}); + +// 3. Kullan +$product = Product::create([ + 'name' => 'Laptop', + 'slug' => 'laptop', + 'description' => 'Yüksek performanslı laptop', + 'price' => 15000, + 'sku' => 'LAP-001' +]); + +$product->setTranslation('name', 'en', 'Laptop', 'published'); +$product->setTranslation('description', 'en', 'High performance laptop', 'published'); + +// Artık Product'da çeviri var! 🎉 +echo $product->translate('name', 'en'); // "Laptop" +``` + +### Örnek 4: Tüm Çevirileri Getir + +```php +$blog = Blog::find(1); + +// Bir alanın tüm dillerdeki çevirileri +$titleTranslations = $blog->getTranslations('title'); +foreach ($titleTranslations as $langCode => $translation) { + echo "{$langCode}: {$translation->field_value}\n"; +} +// tr: Laravel Eğitimi +// en: Laravel Tutorial +// de: Laravel Tutorial + +// Bir dilin tüm alan çevirileri +$enTranslations = $blog->getTranslationsForLanguage('en'); +foreach ($enTranslations as $fieldName => $translation) { + echo "{$fieldName}: {$translation->field_value}\n"; +} +// title: Laravel Tutorial +// content: Detailed info about Laravel... +// excerpt: What is Laravel? +``` + +### Örnek 5: Helper Functions + +```php +// Mevcut dil +$currentLang = current_language(); // Language model +$currentCode = current_language_code(); // 'tr' + +// Varsayılan dil +$defaultLang = default_language(); +$defaultCode = default_language_code(); // 'tr' + +// Aktif diller +$languages = available_languages(); // Collection [TR, EN] +$codes = available_language_codes(); // ['tr', 'en'] + +// Dil değiştir +switch_language('en'); +app()->setLocale('en'); + +// Model çevirisi +$title = translate_model($blog, 'title', 'en'); + +// Çeviri var mı? +if (has_translation($blog, 'title', 'en')) { + echo "English translation exists!"; +} + +// İlerleme +$progress = translation_progress($blog, 'en'); // 75 + +// Kullanıcı dilleri +$userLanguages = user_managed_languages(); // Collection +$userCodes = user_managed_language_codes(); // ['tr', 'en'] + +// Dil bilgileri +echo get_language_flag('tr'); // 🇹🇷 +echo format_language_name('tr'); // Turkish (Türkçe) +``` + +## 🎯 Sık Sorulan Sorular + +### 1. Varsayılan dilde çeviri lazım mı? +Hayır! Varsayılan dil (TR) için veritabanındaki orijinal değer kullanılır. Sadece diğer diller için çeviri eklemeniz yeterli. + +### 2. Çeviri yoksa ne olur? +Fallback mekanizması devreye girer: +1. İstenen dilde ara +2. Yoksa varsayılan dilde ara +3. Yoksa model'deki orijinal değeri kullan + +### 3. Draft çeviriler görünür mü? +Hayır, sadece `published` çeviriler görünür. Ama parametre ile draft'ları da alabilirsiniz: +```php +$translation = $blog->getTranslation('title', 'en', published: false); +``` + +### 4. Toplu çeviri nasıl yapılır? +```php +// TR'den EN'e tüm alanları kopyala +$blog->duplicateTranslation('tr', 'en'); + +// Tüm diller için placeholder oluştur +$blog->syncTranslations(['tr', 'en', 'de']); +``` + +### 5. Performans nasıl? +- ✅ Indexed queries +- ✅ Eager loading desteği +- ✅ Tek query ile tüm çevirileri çek +- ✅ Cache-ready yapı + +## 📊 Dashboard Özet Örneği + +```php +// Admin dashboard widget örneği +$stats = [ + 'total_languages' => Language::active()->count(), + 'total_translations' => Translation::published()->count(), + 'pending_approvals' => Translation::review()->count(), + 'translation_progress' => [] +]; + +foreach (Language::active()->get() as $language) { + $stats['translation_progress'][$language->code] = [ + 'name' => $language->name, + 'flag' => $language->flag, + 'count' => Translation::forLanguage($language->code)->published()->count(), + ]; +} +``` + +## 🎨 Blade Template Kullanımı + +```blade +{{-- Mevcut dilde --}} +