From 0ba13ab84254edc6bfe9fb5c4f5028a5abab45b8 Mon Sep 17 00:00:00 2001 From: Emmanuel Paul Date: Tue, 29 Jul 2025 20:25:21 -0400 Subject: [PATCH 1/8] frontend configuration + middleware to set app locale --- .../Settings/LanguageController.php | 27 ++++ app/Http/Middleware/HandleInertiaRequests.php | 6 + app/Http/Middleware/SetUserLocale.php | 82 +++++++++++ app/Models/User.php | 1 + bootstrap/app.php | 2 + config/app.php | 16 +++ .../0001_01_01_000000_create_users_table.php | 1 + package-lock.json | 89 ++++++++++++ package.json | 1 + resources/js/app.ts | 16 ++- resources/js/components/AppHeader.vue | 18 ++- resources/js/components/AppLogo.vue | 5 +- resources/js/components/AppSidebar.vue | 16 ++- resources/js/components/AppearanceTabs.vue | 8 +- resources/js/components/DeleteUser.vue | 23 +-- resources/js/components/LanguageSwitcher.vue | 66 +++++++++ resources/js/components/LanguageTab.vue | 85 +++++++++++ resources/js/components/NavMain.vue | 4 +- resources/js/components/UserMenuContent.vue | 17 ++- .../js/components/ui/sidebar/Sidebar.vue | 4 +- .../components/ui/sidebar/SidebarTrigger.vue | 4 +- resources/js/composables/useLocale.ts | 41 ++++++ resources/js/i18n/index.ts | 13 ++ resources/js/i18n/locales/en.ts | 136 ++++++++++++++++++ resources/js/i18n/locales/fr.ts | 136 ++++++++++++++++++ resources/js/layouts/settings/Layout.vue | 19 ++- resources/js/pages/Dashboard.vue | 12 +- resources/js/pages/Welcome.vue | 25 ++-- resources/js/pages/auth/ConfirmPassword.vue | 11 +- resources/js/pages/auth/ForgotPassword.vue | 21 +-- resources/js/pages/auth/Login.vue | 23 +-- resources/js/pages/auth/Register.vue | 65 +++++---- resources/js/pages/auth/ResetPassword.vue | 21 +-- resources/js/pages/auth/VerifyEmail.vue | 13 +- resources/js/pages/settings/Appearance.vue | 14 +- resources/js/pages/settings/Language.vue | 34 +++++ resources/js/pages/settings/Password.vue | 31 ++-- resources/js/pages/settings/Profile.vue | 26 ++-- resources/js/types/index.d.ts | 2 + routes/settings.php | 7 + 40 files changed, 982 insertions(+), 159 deletions(-) create mode 100644 app/Http/Controllers/Settings/LanguageController.php create mode 100644 app/Http/Middleware/SetUserLocale.php create mode 100644 resources/js/components/LanguageSwitcher.vue create mode 100644 resources/js/components/LanguageTab.vue create mode 100644 resources/js/composables/useLocale.ts create mode 100644 resources/js/i18n/index.ts create mode 100644 resources/js/i18n/locales/en.ts create mode 100644 resources/js/i18n/locales/fr.ts create mode 100644 resources/js/pages/settings/Language.vue diff --git a/app/Http/Controllers/Settings/LanguageController.php b/app/Http/Controllers/Settings/LanguageController.php new file mode 100644 index 00000000..9aac57fd --- /dev/null +++ b/app/Http/Controllers/Settings/LanguageController.php @@ -0,0 +1,27 @@ +validate([ + 'locale' => ['required', 'string', Rule::in(['en', 'fr'])], + ]); + + $request->user()->update([ + 'locale' => $validated['locale'], + ]); + + return back()->with('status', 'Language updated successfully'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 62b69f31..7c61fea6 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Http\Request; +use Illuminate\Support\Facades\App; use Inertia\Middleware; use Tighten\Ziggy\Ziggy; @@ -51,6 +52,11 @@ public function share(Request $request): array 'location' => $request->url(), ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', + 'locale' => [ + 'current' => App::getLocale(), + 'available' => config('app.available_locales'), + 'user_preference' => $request->user()?->locale, + ], ]; } } diff --git a/app/Http/Middleware/SetUserLocale.php b/app/Http/Middleware/SetUserLocale.php new file mode 100644 index 00000000..e50edf93 --- /dev/null +++ b/app/Http/Middleware/SetUserLocale.php @@ -0,0 +1,82 @@ +user()?->locale) { + App::setLocale($userLocale); + return $next($request); + } + + // Priority 2: Session locale (for guest users who changed language) + if ($sessionLocale = $request->session()->get('locale')) { + if (in_array($sessionLocale, $this->getSupportedLocales())) { + App::setLocale($sessionLocale); + return $next($request); + } + } + + // Priority 3: Browser language preferences + $this->setLocaleFromBrowser($request); + + return $next($request); + } + + /** + * Set locale based on browser language preferences + */ + private function setLocaleFromBrowser(Request $request): void + { + $browserLocales = $request->getLanguages(); + $supportedLocales = $this->getSupportedLocales(); + + foreach ($browserLocales as $browserLocale) { + // Try exact match first (e.g., en-US) + $normalizedLocale = str_replace('_', '-', strtolower($browserLocale)); + if (in_array($normalizedLocale, $supportedLocales)) { + App::setLocale($normalizedLocale); + return; + } + + // Try language code only (e.g., en from en-US) + $languageCode = strtolower(substr($browserLocale, 0, 2)); + if (in_array($languageCode, $supportedLocales)) { + App::setLocale($languageCode); + return; + } + + // Try to find a supported locale that starts with the language code + foreach ($supportedLocales as $supportedLocale) { + if (str_starts_with($supportedLocale, $languageCode . '-')) { + App::setLocale($supportedLocale); + return; + } + } + } + + // Fallback to default locale if no match found + App::setLocale(config('app.locale')); + } + + /** + * Get supported locales from config + */ + private function getSupportedLocales(): array + { + return config('app.available_locales'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b77..c38962f9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -20,6 +20,7 @@ class User extends Authenticatable protected $fillable = [ 'name', 'email', + 'locale', 'password', ]; diff --git a/bootstrap/app.php b/bootstrap/app.php index 134581ab..a7f54199 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,7 @@ use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\SetUserLocale; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -18,6 +19,7 @@ $middleware->web(append: [ HandleAppearance::class, + SetUserLocale::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ]); diff --git a/config/app.php b/config/app.php index 324b513a..dcc5a628 100644 --- a/config/app.php +++ b/config/app.php @@ -123,4 +123,20 @@ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + /* + |-------------------------------------------------------------------------- + | Available Locales + |-------------------------------------------------------------------------- + | + | This array defines the list of supported locales for your application. + | These will be used to validate user preferences, detect browser languages, + | and limit localization to only approved languages. + | + */ + + 'available_locales' => [ + 'en', + 'fr' + ], + ]; diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9e..a2f1eb0a 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -15,6 +15,7 @@ public function up(): void $table->id(); $table->string('name'); $table->string('email')->unique(); + $table->string('locale', 5)->default('en'); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); diff --git a/package-lock.json b/package-lock.json index e74ef58c..58887dba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "typescript": "^5.2.2", "vite": "^7.0.4", "vue": "^3.5.13", + "vue-i18n": "^12.0.0-alpha.3", "ziggy-js": "^2.4.2" }, "devDependencies": { @@ -863,6 +864,67 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@intlify/core-base": { + "version": "12.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-12.0.0-alpha.3.tgz", + "integrity": "sha512-LEvBHBUbiOOtIBkp4IIQENVC5Fg2YHsvdXN1+WRIxQ8hzHbHSBiqZ2l68B/yg8sE1a4S7dqhkaAedunShWPH+Q==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "12.0.0-alpha.3", + "@intlify/shared": "12.0.0-alpha.3" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "12.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-12.0.0-alpha.3.tgz", + "integrity": "sha512-mDDTN3gfYOHhBnpnlby19UHyvMaOnzdlpsIrxUfs44R/vCATfn8pMOkE8PXD2t410xkocEj3FpDcC9XC/0v4Dg==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "12.0.0-alpha.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "12.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-12.0.0-alpha.3.tgz", + "integrity": "sha512-ryaNYBvxQjyJUmVuBBg+HHUsmGnfxcEUPR0NCeG4/K9N2qtyFE35C80S15IN6iYFE2MGWLN7HfOSyg0MXZIc9w==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/vue-i18n-core": { + "version": "12.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@intlify/vue-i18n-core/-/vue-i18n-core-12.0.0-alpha.3.tgz", + "integrity": "sha512-YwAfTQILHN+VoK0P/Yv47GbKnEf1lhfbliyVyW3knAL1EmT8m0m3rwffXJnwyQhYw8Jpx85CpL49WkSgyi6d/g==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "12.0.0-alpha.3", + "@intlify/shared": "12.0.0-alpha.3", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1897,6 +1959,12 @@ "he": "^1.2.0" } }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, "node_modules/@vue/eslint-config-typescript": { "version": "14.6.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.6.0.tgz", @@ -4939,6 +5007,27 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/vue-i18n": { + "version": "12.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-12.0.0-alpha.3.tgz", + "integrity": "sha512-+KQgD9LJoHfGCdJh3gaLdVS/Sps1n860+6wsjyeNLWJeEofjdVH7KPjz4rAeBlTAUaIDlIjHoXQY0Lk+8B6S9w==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "12.0.0-alpha.3", + "@intlify/shared": "12.0.0-alpha.3", + "@intlify/vue-i18n-core": "12.0.0-alpha.3", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-tsc": { "version": "2.2.12", "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", diff --git a/package.json b/package.json index 65a98817..6fafb145 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "typescript": "^5.2.2", "vite": "^7.0.4", "vue": "^3.5.13", + "vue-i18n": "^12.0.0-alpha.3", "ziggy-js": "^2.4.2" }, "optionalDependencies": { diff --git a/resources/js/app.ts b/resources/js/app.ts index 792a46cf..9be231c5 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -6,6 +6,7 @@ import type { DefineComponent } from 'vue'; import { createApp, h } from 'vue'; import { ZiggyVue } from 'ziggy-js'; import { initializeTheme } from './composables/useAppearance'; +import i18n from './i18n'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; @@ -13,10 +14,17 @@ createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), resolve: (name) => resolvePageComponent(`./pages/${name}.vue`, import.meta.glob('./pages/**/*.vue')), setup({ el, App, props, plugin }) { - createApp({ render: () => h(App, props) }) - .use(plugin) - .use(ZiggyVue) - .mount(el); + const app = createApp({ render: () => h(App, props) }); + + // Handle the new locale structure where locale is an object with 'current' property + const localeData = (props.initialPage.props as any)?.locale; + const currentLocale = typeof localeData === 'object' && localeData?.current + ? localeData.current + : (typeof localeData === 'string' ? localeData : 'en'); + + i18n.global.locale.value = currentLocale; + + app.use(plugin).use(ZiggyVue).use(i18n).mount(el); }, progress: { color: '#4B5563', diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index 51296fdd..b1cdaeef 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -14,6 +14,7 @@ import type { BreadcrumbItem, NavItem } from '@/types'; import { Link, usePage } from '@inertiajs/vue3'; import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-vue-next'; import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; interface Props { breadcrumbs?: BreadcrumbItem[]; @@ -25,6 +26,7 @@ const props = withDefaults(defineProps(), { const page = usePage(); const auth = computed(() => page.props.auth); +const { t } = useI18n(); const isCurrentRoute = computed(() => (url: string) => page.url === url); @@ -32,26 +34,26 @@ const activeItemStyles = computed( () => (url: string) => (isCurrentRoute.value(url) ? 'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100' : ''), ); -const mainNavItems: NavItem[] = [ +const mainNavItems = computed(() => [ { - title: 'Dashboard', + title: t('nav.dashboard'), href: '/dashboard', icon: LayoutGrid, }, -]; +]); -const rightNavItems: NavItem[] = [ +const rightNavItems = computed(() => [ { title: 'Repository', href: 'https://github.com/laravel/vue-starter-kit', icon: Folder, }, { - title: 'Documentation', + title: t('welcome.documentation'), href: 'https://laravel.com/docs/starter-kits#vue', icon: BookOpen, }, -]; +]);