🌐 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

@@ -3,3 +3,16 @@
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
const head = useLocaleHead({
addDirAttribute: true,
addSeoAttributes: true,
})
useHead({
htmlAttrs: computed(() => head.value.htmlAttrs ?? {}),
link: computed(() => head.value.link ?? []),
meta: computed(() => head.value.meta ?? []),
})
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div class="flex items-center gap-1" role="group" :aria-label="$t('common.language')">
<button
v-for="loc in availableLocales"
:key="loc.code"
:aria-current="currentLocale === loc.code ? 'true' : undefined"
:class="[
'px-2 py-1 text-sm font-ui rounded transition-colors',
currentLocale === loc.code
? 'text-sky-accent font-semibold'
: 'text-sky-text/50 hover:text-sky-text',
]"
@click="switchLocale(loc.code)"
>
{{ loc.code.toUpperCase() }}
</button>
</div>
</template>
<script setup lang="ts">
const { currentLocale, availableLocales, switchLocale } = useLocale()
</script>

View File

@@ -0,0 +1,18 @@
export const useApi = () => {
const config = useRuntimeConfig()
const { locale } = useI18n()
const apiFetch = async <T>(endpoint: string, options: Record<string, any> = {}) => {
return await $fetch<T>(`${config.public.apiUrl}${endpoint}`, {
...options,
headers: {
'X-API-Key': config.public.apiKey as string,
'Accept-Language': locale.value,
'Content-Type': 'application/json',
...options.headers,
},
})
}
return { apiFetch }
}

View File

@@ -0,0 +1,29 @@
export const useLocale = () => {
const { locale, locales, setLocale } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const localePath = useLocalePath()
const currentLocale = computed(() => locale.value)
const availableLocales = computed(() =>
(locales.value as Array<{ code: string; name: string }>).map(l => ({
code: l.code,
name: l.name,
}))
)
const switchLocale = (code: string) => {
return navigateTo(switchLocalePath(code))
}
const localizedPath = (path: string) => {
return localePath(path)
}
return {
currentLocale,
availableLocales,
switchLocale,
localizedPath,
}
}

View File

@@ -1,5 +1,14 @@
<template>
<div class="min-h-screen bg-sky-dark flex items-center justify-center">
<h1 class="text-4xl font-ui text-sky-text">Skycel</h1>
<div class="min-h-screen bg-sky-dark flex flex-col items-center justify-center gap-6">
<h1 class="text-4xl font-narrative text-sky-text">{{ $t('landing.title') }}</h1>
<p class="text-xl font-ui text-sky-text/70">{{ $t('landing.subtitle') }}</p>
<div class="flex gap-4 mt-4">
<button class="px-6 py-3 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg">
{{ $t('landing.cta_adventure') }}
</button>
<button class="px-6 py-3 border border-sky-text/30 text-sky-text font-ui rounded-lg">
{{ $t('landing.cta_express') }}
</button>
</div>
</div>
</template>

37
frontend/i18n/en.json Normal file
View File

@@ -0,0 +1,37 @@
{
"nav": {
"home": "Home",
"projects": "Projects",
"skills": "Skills",
"testimonials": "Testimonials",
"journey": "Journey",
"contact": "Contact",
"resume": "Quick Resume"
},
"common": {
"continue": "Continue",
"back": "Back",
"discover": "Discover",
"close": "Close",
"loading": "Loading...",
"language": "Language"
},
"landing": {
"title": "Welcome to my universe",
"subtitle": "Full-Stack Developer",
"cta_adventure": "Start the adventure",
"cta_express": "Express mode"
},
"error": {
"404": "Page not found",
"generic": "An error occurred"
},
"meta": {
"title": "Skycel - Célian's Portfolio",
"description": "Discover my interactive and gamified portfolio"
},
"footer": {
"copyright": "© {year} Célian — Skycel",
"built_with": "Built with Nuxt & Laravel"
}
}

37
frontend/i18n/fr.json Normal file
View File

@@ -0,0 +1,37 @@
{
"nav": {
"home": "Accueil",
"projects": "Projets",
"skills": "Compétences",
"testimonials": "Témoignages",
"journey": "Parcours",
"contact": "Contact",
"resume": "Résumé Express"
},
"common": {
"continue": "Continuer",
"back": "Retour",
"discover": "Découvrir",
"close": "Fermer",
"loading": "Chargement...",
"language": "Langue"
},
"landing": {
"title": "Bienvenue dans mon univers",
"subtitle": "Développeur Full-Stack",
"cta_adventure": "Partir à l'aventure",
"cta_express": "Mode express"
},
"error": {
"404": "Page non trouvée",
"generic": "Une erreur est survenue"
},
"meta": {
"title": "Skycel - Portfolio de Célian",
"description": "Découvrez mon portfolio interactif et gamifié"
},
"footer": {
"copyright": "© {year} Célian — Skycel",
"built_with": "Construit avec Nuxt & Laravel"
}
}

View File

@@ -24,9 +24,25 @@ export default defineNuxtConfig({
},
i18n: {
locales: ['fr', 'en'],
locales: [
{ code: 'fr', iso: 'fr-FR', file: 'fr.json', name: 'Français' },
{ code: 'en', iso: 'en-US', file: 'en.json', name: 'English' },
],
defaultLocale: 'fr',
strategy: 'prefix_except_default',
lazy: true,
langDir: '../i18n/',
detectBrowserLanguage: false,
pages: {
'projets/index': { en: '/projects' },
'projets/[slug]': { en: '/projects/[slug]' },
'competences': { en: '/skills' },
'temoignages': { en: '/testimonials' },
'parcours': { en: '/journey' },
'contact': { en: '/contact' },
'resume': { en: '/resume' },
},
baseUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://skycel.fr',
},
app: {