move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Modules\Localization\Concerns;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Modules\Localization\Models\Locale;
use Modules\Localization\Models\LocalizedContent;
trait HasTranslations
{
public function translations(): MorphMany
{
return $this->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]
);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
use Modules\Blog\Models\Blog;
use Modules\News\Models\News;
use Modules\Pages\Models\Page;
return [
'default_locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'session_key' => 'platform_locale',
'supported_subjects' => [
'pages' => ['model' => Page::class, 'label' => 'Pages', 'fields' => ['title', 'summary', 'content', 'meta_title', 'meta_description']],
'blog' => ['model' => Blog::class, 'label' => 'Blog', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']],
'news' => ['model' => News::class, 'label' => 'News', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']],
],
];
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('locales', function (Blueprint $table): void {
$table->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');
}
};
@@ -0,0 +1,19 @@
<?php
namespace Modules\Localization\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Localization\Models\Locale;
final class LocaleSeeder extends Seeder
{
public function run(): void
{
Locale::query()->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,
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace Modules\Localization\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Localization\Models\Locale;
use Modules\Localization\Services\LocaleManager;
final class LocaleController extends Controller
{
public function index(): View
{
abort_unless(auth()->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'],
];
}
}
@@ -0,0 +1,44 @@
<?php
namespace Modules\Localization\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Localization\Models\Locale;
final class TranslationController extends Controller
{
public function edit(string $subject, int $id): View
{
abort_unless(auth()->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];
}
}
@@ -0,0 +1,53 @@
<?php
namespace Modules\Localization\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Modules\Localization\Models\Locale;
use Symfony\Component\HttpFoundation\Response;
final class SetLocale
{
public function handle(Request $request, Closure $next): Response
{
$sessionKey = (string) config('localization.session_key', 'locale');
$fallback = (string) config('localization.default_locale', config('app.locale', 'en'));
$code = $request->route('locale');
if (! $code && $request->hasSession()) {
$code = $request->session()->get($sessionKey);
}
if (! Schema::hasTable('locales')) {
app()->setLocale(is_string($code) && $code !== '' ? $code : $fallback);
return $next($request);
}
if (! $code) {
$code = Locale::query()
->where('is_default', true)
->value('code') ?: $fallback;
}
$isActive = Locale::query()
->where('code', $code)
->where('is_active', true)
->exists();
if ($isActive) {
app()->setLocale((string) $code);
if ($request->hasSession()) {
$request->session()->put($sessionKey, $code);
}
} else {
app()->setLocale($fallback);
}
return $next($request);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Modules\Localization\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Locale extends Model
{
protected $fillable = ['code', 'name', 'native_name', 'regional_code', 'direction', 'date_format', 'time_format', 'timezone', 'currency_code', 'currency_symbol', 'position', 'is_default', 'is_active'];
protected function casts(): array
{
return ['is_default' => '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');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Localization\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
final class LocalizedContent extends Model
{
protected $fillable = ['locale_id', 'field', 'value', 'updated_by'];
public function translatable(): MorphTo
{
return $this->morphTo();
}
public function locale(): BelongsTo
{
return $this->belongsTo(Locale::class);
}
public function editor(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Localization\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Localization\Services\LocaleManager;
final class LocalizationServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->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');
}
}
@@ -0,0 +1,15 @@
<?php
namespace Modules\Localization\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
parent::boot();
Route::middleware('web')->group(module_path('Localization', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="__('Localisation')"><main class="p-6 space-y-6"><div><flux:heading size="xl">Localisation</flux:heading><flux:text>Languages, regional formats and translated content.</flux:text></div><div class="grid gap-6 xl:grid-cols-3"><section class="xl:col-span-2 space-y-4"><flux:heading>Locales</flux:heading>@foreach($locales as $locale)<form method="POST" action="{{ route('administration.localization.locales.update',$locale) }}" class="rounded-xl border p-4 grid gap-3 md:grid-cols-4">@csrf @method('PUT')<flux:input name="code" label="Code" :value="$locale->code"/><flux:input name="name" label="Name" :value="$locale->name"/><flux:input name="native_name" label="Native name" :value="$locale->native_name"/><flux:input name="regional_code" label="Regional code" :value="$locale->regional_code"/><flux:select name="direction" label="Direction"><option value="ltr" @selected($locale->direction==='ltr')>LTR</option><option value="rtl" @selected($locale->direction==='rtl')>RTL</option></flux:select><flux:input name="timezone" label="Timezone" :value="$locale->timezone"/><flux:input name="currency_code" label="Currency" :value="$locale->currency_code"/><flux:input name="currency_symbol" label="Symbol" :value="$locale->currency_symbol"/><input type="hidden" name="date_format" value="{{ $locale->date_format }}"><input type="hidden" name="time_format" value="{{ $locale->time_format }}"><input type="hidden" name="position" value="{{ $locale->position }}"><label class="flex items-center gap-2"><input type="hidden" name="is_active" value="0"><input type="checkbox" name="is_active" value="1" @checked($locale->is_active)> Active</label><label class="flex items-center gap-2"><input type="hidden" name="is_default" value="0"><input type="checkbox" name="is_default" value="1" @checked($locale->is_default)> Default</label><div class="md:col-span-2 flex justify-end"><flux:button type="submit" variant="primary">Save</flux:button></div></form>@endforeach</section><section class="rounded-xl border p-5"><flux:heading>Add locale</flux:heading><form method="POST" action="{{ route('administration.localization.locales.store') }}" class="mt-4 space-y-3">@csrf<flux:input name="code" label="Code" placeholder="fr" required/><flux:input name="name" label="Name" placeholder="French" required/><flux:input name="native_name" label="Native name" placeholder="Français" required/><flux:input name="regional_code" label="Regional code" placeholder="fr-FR"/><flux:select name="direction" label="Direction"><option value="ltr">LTR</option><option value="rtl">RTL</option></flux:select><flux:input name="timezone" label="Timezone" value="UTC" required/><flux:input name="currency_code" label="Currency code"/><flux:input name="currency_symbol" label="Currency symbol"/><input type="hidden" name="date_format" value="d/m/Y"><input type="hidden" name="time_format" value="H:i"><input type="hidden" name="position" value="0"><input type="hidden" name="is_active" value="1"><flux:button type="submit" variant="primary">Add locale</flux:button></form></section></div><section class="space-y-4"><flux:heading>Translate content</flux:heading><div class="grid gap-4 lg:grid-cols-3">@foreach($subjects as $key=>$subject)<div class="rounded-xl border p-4"><div class="font-semibold">{{ $subject['label'] }}</div><div class="mt-3 space-y-2">@foreach($subject['records'] as $record)<a class="block rounded-lg border px-3 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800" href="{{ route('administration.localization.translations.edit',[$key,$record->id]) }}">{{ $record->title ?? ('#'.$record->id) }}</a>@endforeach</div></div>@endforeach</div></section></main></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="__('Translate content')"><main class="p-6 space-y-6"><div><flux:heading size="xl">Translate: {{ $record->title ?? ('#'.$record->id) }}</flux:heading><flux:text>{{ $config['label'] }} · source language values are shown for reference.</flux:text></div><form method="POST" action="{{ route('administration.localization.translations.update',[$subject,$record->id]) }}" class="space-y-6">@csrf @method('PUT')@foreach($locales as $locale)<section class="rounded-xl border p-5 space-y-4"><div class="flex items-center justify-between"><flux:heading>{{ $locale->native_name }}</flux:heading><span class="text-sm text-zinc-500">{{ $locale->code }}</span></div>@foreach($config['fields'] as $field)@php($existing=$record->translations->first(fn($item)=>$item->locale_id===$locale->id&&$item->field===$field)?->value)<div><div class="mb-1 text-sm font-medium">{{ str($field)->headline() }}</div><div class="mb-2 text-xs text-zinc-500">Source: {{ str(strip_tags((string)$record->getAttribute($field)))->limit(180) }}</div>@if(in_array($field,['content','summary','excerpt','meta_description']))<flux:textarea name="translations[{{ $locale->code }}][{{ $field }}]" rows="6">{{ $existing }}</flux:textarea>@else<flux:input name="translations[{{ $locale->code }}][{{ $field }}]" :value="$existing"/>@endif</div>@endforeach</section>@endforeach<div class="flex justify-end"><flux:button type="submit" variant="primary">Save translations</flux:button></div></form></main></x-layouts.app>
+15
View File
@@ -0,0 +1,15 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Localization\Http\Controllers\LocaleController;
use Modules\Localization\Http\Controllers\TranslationController;
Route::get('/locale/{locale}', [LocaleController::class, 'switch'])->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');
});
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Localization\Services;
use Illuminate\Support\Collection;
use Modules\Localization\Models\Locale;
final class LocaleManager
{
public function active(): Collection
{
return Locale::query()->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;
}
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Localization\Tests\Feature;
use Modules\Localization\Providers\LocalizationServiceProvider;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_localization_module_is_registered(): void
{
$this->assertTrue(app()->providerIsLoaded(LocalizationServiceProvider::class));
}
}
+9
View File
@@ -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": []
}