diff --git a/Modules/Blog/Models/Blog.php b/Modules/Blog/Models/Blog.php index a06ef09..b202da7 100644 --- a/Modules/Blog/Models/Blog.php +++ b/Modules/Blog/Models/Blog.php @@ -1,4 +1,4 @@ 'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'blog_post_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Modules\Authors\Models\Author; use Modules\Categories\Models\Category; use Modules\Tags\Models\Tag; use Modules\Workflow\Concerns\HasWorkflow; use Modules\Localization\Concerns\HasTranslations; +final class Blog extends Model { use HasTranslations, HasWorkflow; protected $table='blog_posts'; protected $fillable=['author_id','category_id','title','slug','excerpt','content','featured_image','status','is_featured','published_at','expires_at','meta_title','meta_description']; protected function casts(): array { return ['is_featured'=>'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'blog_post_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } diff --git a/Modules/Localization/Concerns/HasTranslations.php b/Modules/Localization/Concerns/HasTranslations.php new file mode 100644 index 0000000..993dea5 --- /dev/null +++ b/Modules/Localization/Concerns/HasTranslations.php @@ -0,0 +1,27 @@ +morphMany(LocalizedContent::class, 'translatable'); + } + public function translated(string $field, ?string $locale = null, mixed $fallback = null): mixed + { + $locale ??= app()->getLocale(); + $translation = $this->translations->first(fn (LocalizedContent $item): bool => $item->field === $field && $item->locale?->code === $locale); + if ($translation && filled($translation->value)) { return $translation->value; } + return $fallback ?? $this->getAttribute($field); + } + public function setTranslation(string $field, string $localeCode, ?string $value, ?int $userId = null): LocalizedContent + { + $locale = Locale::query()->where('code', $localeCode)->firstOrFail(); + return $this->translations()->updateOrCreate( + ['locale_id' => $locale->id, 'field' => $field], + ['value' => $value, 'updated_by' => $userId] + ); + } +} diff --git a/Modules/Localization/Config/config.php b/Modules/Localization/Config/config.php new file mode 100644 index 0000000..e5f5736 --- /dev/null +++ b/Modules/Localization/Config/config.php @@ -0,0 +1,11 @@ + env('APP_LOCALE', 'en'), + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + 'session_key' => 'platform_locale', + 'supported_subjects' => [ + 'pages' => ['model' => Modules\Pages\Models\Page::class, 'label' => 'Pages', 'fields' => ['title', 'summary', 'content', 'meta_title', 'meta_description']], + 'blog' => ['model' => Modules\Blog\Models\Blog::class, 'label' => 'Blog', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']], + 'news' => ['model' => Modules\News\Models\News::class, 'label' => 'News', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']], + ], +]; diff --git a/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php b/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php new file mode 100644 index 0000000..592570a --- /dev/null +++ b/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php @@ -0,0 +1,43 @@ +id(); + $table->string('code', 20)->unique(); + $table->string('name', 100); + $table->string('native_name', 100); + $table->string('regional_code', 20)->nullable(); + $table->string('direction', 3)->default('ltr'); + $table->string('date_format', 40)->default('d/m/Y'); + $table->string('time_format', 40)->default('H:i'); + $table->string('timezone', 80)->default('UTC'); + $table->string('currency_code', 3)->nullable(); + $table->string('currency_symbol', 10)->nullable(); + $table->unsignedInteger('position')->default(0); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + Schema::create('localized_contents', function (Blueprint $table): void { + $table->id(); + $table->morphs('translatable'); + $table->foreignId('locale_id')->constrained()->cascadeOnDelete(); + $table->string('field', 100); + $table->longText('value')->nullable(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->unique(['translatable_type', 'translatable_id', 'locale_id', 'field'], 'localized_content_unique'); + $table->index(['locale_id', 'field']); + }); + } + public function down(): void + { + Schema::dropIfExists('localized_contents'); + Schema::dropIfExists('locales'); + } +}; diff --git a/Modules/Localization/Database/Seeders/LocaleSeeder.php b/Modules/Localization/Database/Seeders/LocaleSeeder.php new file mode 100644 index 0000000..1570ff8 --- /dev/null +++ b/Modules/Localization/Database/Seeders/LocaleSeeder.php @@ -0,0 +1,16 @@ +updateOrCreate(['code' => 'en'], [ + 'name' => 'English', 'native_name' => 'English', 'regional_code' => 'en-GB', + 'direction' => 'ltr', 'date_format' => 'd/m/Y', 'time_format' => 'H:i', + 'timezone' => config('app.timezone', 'UTC'), 'currency_code' => 'GBP', + 'currency_symbol' => '£', 'position' => 0, 'is_default' => true, 'is_active' => true, + ]); + } +} diff --git a/Modules/Localization/Http/Controllers/LocaleController.php b/Modules/Localization/Http/Controllers/LocaleController.php new file mode 100644 index 0000000..6f007b6 --- /dev/null +++ b/Modules/Localization/Http/Controllers/LocaleController.php @@ -0,0 +1,60 @@ +user()->can('localization.manage'), 403); + $subjects = collect(config('localization.supported_subjects'))->map(function (array $config): array { + $model = $config['model']; + return $config + ['records' => $model::query()->latest()->limit(8)->get()]; + }); + return view('localization::admin.index', ['locales' => Locale::query()->orderBy('position')->get(), 'subjects' => $subjects]); + } + public function switch(string $locale, LocaleManager $manager): RedirectResponse + { + $manager->activate($locale); + return back(); + } + public function store(Request $request): RedirectResponse + { + abort_unless($request->user()->can('localization.manage'), 403); + $data = $request->validate($this->rules()); + if (($data['is_default'] ?? false) === true) { Locale::query()->update(['is_default' => false]); } + Locale::query()->create($data); + return back()->with('success', 'Locale created.'); + } + public function update(Request $request, Locale $locale): RedirectResponse + { + abort_unless($request->user()->can('localization.manage'), 403); + $data = $request->validate($this->rules($locale)); + if (($data['is_default'] ?? false) === true) { Locale::query()->whereKeyNot($locale->id)->update(['is_default' => false]); } + $locale->update($data); + return back()->with('success', 'Locale updated.'); + } + public function destroy(Request $request, Locale $locale): RedirectResponse + { + abort_unless($request->user()->can('localization.manage'), 403); + abort_if($locale->is_default, 422, 'The default locale cannot be deleted.'); + $locale->delete(); + return back()->with('success', 'Locale deleted.'); + } + private function rules(?Locale $locale = null): array + { + return [ + 'code' => ['required','string','max:20','unique:locales,code,'.($locale?->id ?? 'NULL')], + 'name' => ['required','string','max:100'], 'native_name' => ['required','string','max:100'], + 'regional_code' => ['nullable','string','max:20'], 'direction' => ['required','in:ltr,rtl'], + 'date_format' => ['required','string','max:40'], 'time_format' => ['required','string','max:40'], + 'timezone' => ['required','timezone'], 'currency_code' => ['nullable','string','size:3'], + 'currency_symbol' => ['nullable','string','max:10'], 'position' => ['nullable','integer','min:0'], + 'is_default' => ['sometimes','boolean'], 'is_active' => ['sometimes','boolean'], + ]; + } +} diff --git a/Modules/Localization/Http/Controllers/TranslationController.php b/Modules/Localization/Http/Controllers/TranslationController.php new file mode 100644 index 0000000..1c27df3 --- /dev/null +++ b/Modules/Localization/Http/Controllers/TranslationController.php @@ -0,0 +1,36 @@ +user()->can('localization.translate'), 403); + [$record, $config] = $this->resolve($subject, $id); + $record->load('translations.locale'); + return view('localization::admin.translate', ['record' => $record, 'subject' => $subject, 'config' => $config, 'locales' => Locale::query()->active()->get()]); + } + public function update(Request $request, string $subject, int $id): RedirectResponse + { + abort_unless($request->user()->can('localization.translate'), 403); + [$record, $config] = $this->resolve($subject, $id); + $data = $request->validate(['translations' => ['array'], 'translations.*' => ['array']]); + foreach ($data['translations'] ?? [] as $localeCode => $fields) { + foreach ($config['fields'] as $field) { + $record->setTranslation($field, $localeCode, $fields[$field] ?? null, $request->user()->id); + } + } + return back()->with('success', 'Translations saved.'); + } + private function resolve(string $subject, int $id): array + { + $config = config("localization.supported_subjects.$subject"); + abort_unless(is_array($config), 404); + $record = $config['model']::query()->findOrFail($id); + return [$record, $config]; + } +} diff --git a/Modules/Localization/Http/Middleware/SetLocale.php b/Modules/Localization/Http/Middleware/SetLocale.php new file mode 100644 index 0000000..9312377 --- /dev/null +++ b/Modules/Localization/Http/Middleware/SetLocale.php @@ -0,0 +1,19 @@ +route('locale') ?: $request->session()->get(config('localization.session_key')); + if (! $code) { $code = Locale::query()->where('is_default', true)->value('code') ?: config('localization.default_locale'); } + if (Locale::query()->where('code', $code)->where('is_active', true)->exists()) { + app()->setLocale($code); + $request->session()->put(config('localization.session_key'), $code); + } + return $next($request); + } +} diff --git a/Modules/Localization/Models/Locale.php b/Modules/Localization/Models/Locale.php new file mode 100644 index 0000000..f204c9d --- /dev/null +++ b/Modules/Localization/Models/Locale.php @@ -0,0 +1,12 @@ + 'boolean', 'is_active' => 'boolean']; } + public function translations(): HasMany { return $this->hasMany(LocalizedContent::class); } + public function scopeActive(Builder $query): Builder { return $query->where('is_active', true)->orderBy('position')->orderBy('name'); } +} diff --git a/Modules/Localization/Models/LocalizedContent.php b/Modules/Localization/Models/LocalizedContent.php new file mode 100644 index 0000000..4c8a489 --- /dev/null +++ b/Modules/Localization/Models/LocalizedContent.php @@ -0,0 +1,13 @@ +morphTo(); } + public function locale(): BelongsTo { return $this->belongsTo(Locale::class); } + public function editor(): BelongsTo { return $this->belongsTo(User::class, 'updated_by'); } +} diff --git a/Modules/Localization/Providers/LocalizationServiceProvider.php b/Modules/Localization/Providers/LocalizationServiceProvider.php new file mode 100644 index 0000000..8e2e6b5 --- /dev/null +++ b/Modules/Localization/Providers/LocalizationServiceProvider.php @@ -0,0 +1,18 @@ +mergeConfigFrom(module_path('Localization', 'Config/config.php'), 'localization'); + $this->app->singleton(LocaleManager::class); + $this->app->register(RouteServiceProvider::class); + } + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Localization', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Localization', 'Resources/views'), 'localization'); + } +} diff --git a/Modules/Localization/Providers/RouteServiceProvider.php b/Modules/Localization/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..4856455 --- /dev/null +++ b/Modules/Localization/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('Localization', 'Routes/web.php')); + } +} diff --git a/Modules/Localization/Resources/views/admin/index.blade.php b/Modules/Localization/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..4dca7c8 --- /dev/null +++ b/Modules/Localization/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +
LocalisationLanguages, regional formats and translated content.
Locales@foreach($locales as $locale)
@csrf @method('PUT')
Save
@endforeach
Add locale
@csrfAdd locale
Translate content
@foreach($subjects as $key=>$subject)
{{ $subject['label'] }}
@foreach($subject['records'] as $record){{ $record->title ?? ('#'.$record->id) }}@endforeach
@endforeach
diff --git a/Modules/Localization/Resources/views/admin/translate.blade.php b/Modules/Localization/Resources/views/admin/translate.blade.php new file mode 100644 index 0000000..c71edbc --- /dev/null +++ b/Modules/Localization/Resources/views/admin/translate.blade.php @@ -0,0 +1 @@ +
Translate: {{ $record->title ?? ('#'.$record->id) }}{{ $config['label'] }} · source language values are shown for reference.
@csrf @method('PUT')@foreach($locales as $locale)
{{ $locale->native_name }}{{ $locale->code }}
@foreach($config['fields'] as $field)@php($existing=$record->translations->first(fn($item)=>$item->locale_id===$locale->id&&$item->field===$field)?->value)
{{ str($field)->headline() }}
Source: {{ str(strip_tags((string)$record->getAttribute($field)))->limit(180) }}
@if(in_array($field,['content','summary','excerpt','meta_description'])){{ $existing }}@else@endif
@endforeach
@endforeach
Save translations
diff --git a/Modules/Localization/Routes/web.php b/Modules/Localization/Routes/web.php new file mode 100644 index 0000000..d5b106a --- /dev/null +++ b/Modules/Localization/Routes/web.php @@ -0,0 +1,13 @@ +name('localization.switch'); +Route::middleware(['auth','verified'])->prefix('administration/localization')->name('administration.localization.')->group(function (): void { + Route::get('/', [LocaleController::class, 'index'])->name('index'); + Route::post('/locales', [LocaleController::class, 'store'])->name('locales.store'); + Route::put('/locales/{locale}', [LocaleController::class, 'update'])->name('locales.update'); + Route::delete('/locales/{locale}', [LocaleController::class, 'destroy'])->name('locales.destroy'); + Route::get('/translate/{subject}/{id}', [TranslationController::class, 'edit'])->name('translations.edit'); + Route::put('/translate/{subject}/{id}', [TranslationController::class, 'update'])->name('translations.update'); +}); diff --git a/Modules/Localization/Services/LocaleManager.php b/Modules/Localization/Services/LocaleManager.php new file mode 100644 index 0000000..ffde98b --- /dev/null +++ b/Modules/Localization/Services/LocaleManager.php @@ -0,0 +1,16 @@ +active()->get(); } + public function default(): ?Locale { return Locale::query()->where('is_default', true)->first() ?? Locale::query()->active()->first(); } + public function activate(string $code): Locale + { + $locale = Locale::query()->active()->where('code', $code)->firstOrFail(); + app()->setLocale($locale->code); + session()->put(config('localization.session_key'), $locale->code); + return $locale; + } +} diff --git a/Modules/Localization/Tests/Feature/ModuleBootTest.php b/Modules/Localization/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..a1e77c9 --- /dev/null +++ b/Modules/Localization/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,10 @@ +assertTrue(app()->providerIsLoaded(\Modules\Localization\Providers\LocalizationServiceProvider::class)); + } +} diff --git a/Modules/Localization/module.json b/Modules/Localization/module.json new file mode 100644 index 0000000..a5e28f7 --- /dev/null +++ b/Modules/Localization/module.json @@ -0,0 +1,9 @@ +{ + "name": "Localization", + "alias": "localization", + "description": "Locales, translated content, regional settings and language switching.", + "keywords": ["localization", "translation", "locale", "language"], + "priority": 36, + "providers": ["Modules\\Localization\\Providers\\LocalizationServiceProvider"], + "files": [] +} diff --git a/Modules/News/Models/News.php b/Modules/News/Models/News.php index 5cd07cb..2b369b3 100644 --- a/Modules/News/Models/News.php +++ b/Modules/News/Models/News.php @@ -1,4 +1,4 @@ 'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'news_article_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Modules\Authors\Models\Author; use Modules\Categories\Models\Category; use Modules\Tags\Models\Tag; use Modules\Workflow\Concerns\HasWorkflow; use Modules\Localization\Concerns\HasTranslations; +final class News extends Model { use HasTranslations, HasWorkflow; protected $table='news_articles'; protected $fillable=['author_id','category_id','title','slug','excerpt','content','featured_image','status','is_featured','published_at','expires_at','meta_title','meta_description']; protected function casts(): array { return ['is_featured'=>'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'news_article_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } diff --git a/Modules/Pages/Models/Page.php b/Modules/Pages/Models/Page.php index 3782be2..db9afca 100644 --- a/Modules/Pages/Models/Page.php +++ b/Modules/Pages/Models/Page.php @@ -6,10 +6,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Modules\Workflow\Concerns\HasWorkflow; +use Modules\Localization\Concerns\HasTranslations; final class Page extends Model { - use HasFactory, HasWorkflow, SoftDeletes; + use HasFactory, HasTranslations, HasWorkflow, SoftDeletes; protected $table = 'pages'; protected $fillable = ['title', 'slug', 'summary', 'content', 'status', 'published_at', 'is_home']; diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index 2448a59..f5a29f8 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -53,6 +53,8 @@ final class RolePermissionSeeder extends Seeder 'workflow.view', 'workflow.manage', 'workflow.review', + 'localization.manage', + 'localization.translate', ]; foreach ($permissions as $permission) { diff --git a/bootstrap/app.php b/bootstrap/app.php index 1085719..a26229d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,7 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->web(append: [\Modules\Localization\Http\Middleware\SetLocale::class]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/modules_statuses.json b/modules_statuses.json index 4b145f9..7bd5ce5 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -31,5 +31,6 @@ "Events": true, "Careers": true, "Locations": true, - "Workflow": true + "Workflow": true, + "Localization": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index 5628076..1b26aa4 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -34,7 +34,7 @@ {{ __('Dashboard') }}{{ __('Administration') }} {{ __('Review Queue') }} {{ __('Users') }}{{ __('Roles & Permissions') }}{{ __('Media') }}{{ __('Search') }} -{{ __('SEO') }}{{ __('Settings') }}{{ __('Modules') }}{{ __('Audit Log') }} +{{ __('Localisation') }}{{ __('SEO') }}{{ __('Settings') }}{{ __('Modules') }}{{ __('Audit Log') }}