🌐 Add full i18n system frontend + API (Story 1.3)

Nuxt i18n with lazy-loaded JSON files, localized routes, hreflang SEO tags,
LanguageSwitcher component. Laravel SetLocale middleware, HasTranslations trait,
API Resources and Controllers for projects/skills with Accept-Language support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 18:17:44 +01:00
parent bba6128236
commit 262242c7df
20 changed files with 472 additions and 77 deletions

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
class ProjectController extends Controller
{
public function index()
{
$projects = Project::with('skills')->ordered()->get();
return ProjectResource::collection($projects)
->additional(['meta' => ['lang' => app()->getLocale()]]);
}
public function show(string $slug)
{
$project = Project::with('skills')->where('slug', $slug)->firstOrFail();
return (new ProjectResource($project))
->additional(['meta' => ['lang' => app()->getLocale()]]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\SkillResource;
use App\Models\Skill;
class SkillController extends Controller
{
public function index()
{
$skills = Skill::ordered()->get()->groupBy('category');
$grouped = $skills->map(fn ($group) => SkillResource::collection($group));
return response()->json([
'data' => $grouped,
'meta' => ['lang' => app()->getLocale()],
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
{
protected array $supportedLocales = ['fr', 'en'];
protected string $fallbackLocale = 'fr';
public function handle(Request $request, Closure $next): Response
{
$locale = $this->parseAcceptLanguage($request->header('Accept-Language'));
app()->setLocale($locale);
$request->attributes->set('locale', $locale);
return $next($request);
}
protected function parseAcceptLanguage(?string $header): string
{
if (!$header) {
return $this->fallbackLocale;
}
$locales = [];
foreach (explode(',', $header) as $part) {
$part = trim($part);
if (preg_match('/^([a-z]{2})(?:-[A-Z]{2})?(?:;q=([0-9.]+))?$/i', $part, $matches)) {
$lang = strtolower($matches[1]);
$quality = isset($matches[2]) ? (float) $matches[2] : 1.0;
$locales[$lang] = $quality;
}
}
arsort($locales);
foreach (array_keys($locales) as $lang) {
if (in_array($lang, $this->supportedLocales)) {
return $lang;
}
}
return $this->fallbackLocale;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'title' => $this->getTranslated('title_key'),
'description' => $this->getTranslated('description_key'),
'short_description' => $this->getTranslated('short_description_key'),
'image' => $this->image,
'url' => $this->url,
'github_url' => $this->github_url,
'date_completed' => $this->date_completed?->format('Y-m-d'),
'is_featured' => $this->is_featured,
'skills' => SkillResource::collection($this->whenLoaded('skills')),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SkillResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'name' => $this->getTranslated('name_key'),
'description' => $this->getTranslated('description_key'),
'icon' => $this->icon,
'category' => $this->category,
'max_level' => $this->max_level,
'pivot' => $this->when($this->pivot, fn () => [
'level_before' => $this->pivot->level_before,
'level_after' => $this->pivot->level_after,
]),
];
}
}

View File

@@ -2,12 +2,14 @@
namespace App\Models;
use App\Traits\HasTranslations;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Project extends Model
{
use HasTranslations;
protected $fillable = [
'slug',
'title_key',

View File

@@ -2,12 +2,14 @@
namespace App\Models;
use App\Traits\HasTranslations;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Skill extends Model
{
use HasTranslations;
protected $fillable = [
'slug',
'name_key',

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Traits;
use App\Models\Translation;
trait HasTranslations
{
public function getTranslated(string $keyField, ?string $lang = null): ?string
{
$lang = $lang ?? request()->attributes->get('locale', 'fr');
$key = $this->{$keyField};
if (!$key) {
return null;
}
return Translation::getTranslation($key, $lang);
}
}

View File

@@ -12,6 +12,7 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->api(append: [
\App\Http\Middleware\SetLocale::class,
\App\Http\Middleware\VerifyApiKey::class,
]);
})

View File

@@ -1,7 +1,13 @@
<?php
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\SkillController;
use Illuminate\Support\Facades\Route;
Route::get('/health', function () {
return response()->json(['status' => 'ok']);
});
Route::get('/projects', [ProjectController::class, 'index']);
Route::get('/projects/{slug}', [ProjectController::class, 'show']);
Route::get('/skills', [SkillController::class, 'index']);