From 774e2043c2b62b658392cf9f8066d12f1651678c Mon Sep 17 00:00:00 2001 From: Meghdad Date: Mon, 18 May 2026 05:47:02 +0330 Subject: [PATCH] implement localization --- app/Http/Controllers/LanguageController.php | 29 +++ app/Http/Middleware/HandleInertiaRequests.php | 43 +++- app/Http/Middleware/SetLocale.php | 56 +++++ app/Http/Requests/UpdateLanguageRequest.php | 30 +++ bootstrap/app.php | 2 + config/localization.php | 50 ++++ lang/ar.json | 8 + lang/en.json | 8 + lang/es.json | 8 + lang/fa.json | 8 + resources/css/app.css | 18 ++ resources/js/app.tsx | 16 +- resources/js/components/app-header.tsx | 26 +- resources/js/components/app-logo.tsx | 2 +- .../js/components/app-sidebar-header.tsx | 2 +- resources/js/components/app-sidebar.tsx | 10 +- resources/js/components/appearance-tabs.tsx | 4 +- resources/js/components/language-switcher.tsx | 224 ++++++++++++++++++ resources/js/components/nav-user.tsx | 2 +- resources/js/components/password-input.tsx | 4 +- .../js/components/two-factor-setup-modal.tsx | 10 +- resources/js/components/ui/dialog.tsx | 4 +- resources/js/components/ui/dropdown-menu.tsx | 18 +- resources/js/components/ui/input-otp.tsx | 2 +- .../js/components/ui/navigation-menu.tsx | 2 +- resources/js/components/ui/select.tsx | 4 +- resources/js/components/ui/sheet.tsx | 2 +- resources/js/components/ui/sidebar.tsx | 12 +- resources/js/components/ui/toggle-group.tsx | 2 +- resources/js/components/user-info.tsx | 2 +- resources/js/components/user-menu-content.tsx | 6 +- .../js/layouts/auth/auth-simple-layout.tsx | 4 +- .../js/layouts/auth/auth-split-layout.tsx | 6 +- resources/js/layouts/settings/layout.tsx | 2 +- resources/js/lib/localization.ts | 17 ++ resources/js/pages/auth/forgot-password.tsx | 2 +- resources/js/pages/auth/login.tsx | 4 +- resources/js/pages/channels/show.tsx | 6 +- resources/js/pages/settings/appearance.tsx | 2 + resources/js/pages/welcome.tsx | 14 +- resources/js/types/global.d.ts | 3 +- resources/js/types/localization.ts | 17 ++ resources/views/app.blade.php | 7 +- routes/web.php | 2 + tests/Feature/LocalizationTest.php | 144 ++++++----- 45 files changed, 704 insertions(+), 140 deletions(-) create mode 100644 app/Http/Controllers/LanguageController.php create mode 100644 app/Http/Middleware/SetLocale.php create mode 100644 app/Http/Requests/UpdateLanguageRequest.php create mode 100644 config/localization.php create mode 100644 resources/js/components/language-switcher.tsx create mode 100644 resources/js/lib/localization.ts diff --git a/app/Http/Controllers/LanguageController.php b/app/Http/Controllers/LanguageController.php new file mode 100644 index 0000000..3524d8c --- /dev/null +++ b/app/Http/Controllers/LanguageController.php @@ -0,0 +1,29 @@ +validated(); + + return back()->withCookie(cookie( + (string) config('localization.cookie', 'locale'), + (string) $validated['locale'], + 60 * 24 * 365, + (string) config('session.path', '/'), + config('session.domain'), + config('session.secure'), + true, + false, + config('session.same_site', 'lax'), + )); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 41021c6..1336100 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -35,19 +35,58 @@ class HandleInertiaRequests extends Middleware */ public function share(Request $request): array { + $fallbackLocale = (string) config('app.fallback_locale'); + $locale = app()->getLocale(); + $locales = $this->locales(); + $direction = $locales[$locale]['direction'] ?? 'ltr'; + return [ ...parent::share($request), 'name' => config('app.name'), 'auth' => [ 'user' => $request->user(), ], - 'fallbackLocale' => (string) config('app.fallback_locale'), - 'locale' => app()->getLocale(), + 'fallbackLocale' => $fallbackLocale, + 'locale' => $locale, + 'localization' => [ + 'locale' => $locale, + 'fallbackLocale' => $fallbackLocale, + 'direction' => $direction, + 'isRtl' => $direction === 'rtl', + 'locales' => $this->localeOptions($locales), + ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'translations' => fn () => $this->translations(), ]; } + /** + * @return array + */ + private function locales(): array + { + $locales = config('localization.locales', []); + + return is_array($locales) ? $locales : []; + } + + /** + * @param array $locales + * @return array + */ + private function localeOptions(array $locales): array + { + return collect($locales) + ->map(fn (array $locale, string $code): array => [ + 'code' => $code, + 'name' => $locale['name'], + 'nativeName' => $locale['native_name'], + 'direction' => $locale['direction'], + ]) + ->values() + ->all(); + } + /** * @return array */ diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php new file mode 100644 index 0000000..5653447 --- /dev/null +++ b/app/Http/Middleware/SetLocale.php @@ -0,0 +1,56 @@ +cookie($cookieName); + $configuredLocale = (string) config('app.locale', 'en'); + + $locale = $this->validLocale($cookieLocale, $supportedLocales) + ?? $this->validLocale($configuredLocale, $supportedLocales) + ?? $this->validLocale((string) config('app.fallback_locale', 'en'), $supportedLocales) + ?? 'en'; + + App::setLocale($locale); + + $response = $next($request); + + if ($request->hasCookie($cookieName) && $this->validLocale($cookieLocale, $supportedLocales) === null) { + $response->headers->setCookie(Cookie::forget( + $cookieName, + (string) config('session.path', '/'), + config('session.domain'), + )); + } + + return $response; + } + + /** + * @param array $supportedLocales + */ + private function validLocale(mixed $locale, array $supportedLocales): ?string + { + if (! is_string($locale)) { + return null; + } + + return in_array($locale, $supportedLocales, true) ? $locale : null; + } +} diff --git a/app/Http/Requests/UpdateLanguageRequest.php b/app/Http/Requests/UpdateLanguageRequest.php new file mode 100644 index 0000000..a40e693 --- /dev/null +++ b/app/Http/Requests/UpdateLanguageRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'locale' => ['required', 'string', Rule::in(array_keys((array) config('localization.locales', [])))], + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index fa812df..d746fa7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Http\Middleware\EnsureUserIsAdmin; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\SetLocale; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -24,6 +25,7 @@ return Application::configure(basePath: dirname(__DIR__)) ]); $middleware->web(append: [ + SetLocale::class, HandleAppearance::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, diff --git a/config/localization.php b/config/localization.php new file mode 100644 index 0000000..4579c2e --- /dev/null +++ b/config/localization.php @@ -0,0 +1,50 @@ + 'locale', + + /* + |-------------------------------------------------------------------------- + | Supported Interface Locales + |-------------------------------------------------------------------------- + | + | These locales are available in the language switcher and are validated + | before being stored in the locale cookie. The direction value controls + | the document dir attribute, RTL layout variants, and RTL font selection. + | + */ + + 'locales' => [ + 'en' => [ + 'name' => 'English', + 'native_name' => 'English', + 'direction' => 'ltr', + ], + 'es' => [ + 'name' => 'Spanish', + 'native_name' => 'Español', + 'direction' => 'ltr', + ], + 'fa' => [ + 'name' => 'Persian', + 'native_name' => 'فارسی', + 'direction' => 'rtl', + ], + 'ar' => [ + 'name' => 'Arabic', + 'native_name' => 'العربية', + 'direction' => 'rtl', + ], + ], +]; diff --git a/lang/ar.json b/lang/ar.json index 08b9cbf..d0a02d9 100644 --- a/lang/ar.json +++ b/lang/ar.json @@ -321,5 +321,13 @@ "or, enter the code manually": "أو أدخل الرمز يدويًا", "pending": "معلّق", "recording": "جارٍ التسجيل", + "Arabic": "العربية", + "Change language": "تغيير اللغة", + "Choose the language used across Nyone.": "اختر اللغة المستخدمة في Nyone.", + "English": "الإنجليزية", + "Interface language": "لغة الواجهة", + "Language": "اللغة", + "Persian": "الفارسية", + "Spanish": "الإسبانية", "unknown": "غير معروف" } diff --git a/lang/en.json b/lang/en.json index e6e5089..8ea7dd1 100644 --- a/lang/en.json +++ b/lang/en.json @@ -321,5 +321,13 @@ "or, enter the code manually": "or, enter the code manually", "pending": "pending", "recording": "recording", + "Arabic": "Arabic", + "Change language": "Change language", + "Choose the language used across Nyone.": "Choose the language used across Nyone.", + "English": "English", + "Interface language": "Interface language", + "Language": "Language", + "Persian": "Persian", + "Spanish": "Spanish", "unknown": "unknown" } diff --git a/lang/es.json b/lang/es.json index c4e44f9..8ef8a8b 100644 --- a/lang/es.json +++ b/lang/es.json @@ -321,5 +321,13 @@ "or, enter the code manually": "o introduce el código manualmente", "pending": "pendiente", "recording": "grabando", + "Arabic": "Árabe", + "Change language": "Cambiar idioma", + "Choose the language used across Nyone.": "Elige el idioma usado en Nyone.", + "English": "Inglés", + "Interface language": "Idioma de la interfaz", + "Language": "Idioma", + "Persian": "Persa", + "Spanish": "Español", "unknown": "desconocido" } diff --git a/lang/fa.json b/lang/fa.json index 7aec1e5..10629b2 100644 --- a/lang/fa.json +++ b/lang/fa.json @@ -321,5 +321,13 @@ "or, enter the code manually": "یا کد را دستی وارد کنید", "pending": "در انتظار", "recording": "در حال ضبط", + "Arabic": "عربی", + "Change language": "تغییر زبان", + "Choose the language used across Nyone.": "زبان استفاده‌شده در Nyone را انتخاب کنید.", + "English": "انگلیسی", + "Interface language": "زبان رابط کاربری", + "Language": "زبان", + "Persian": "فارسی", + "Spanish": "اسپانیایی", "unknown": "نامشخص" } diff --git a/resources/css/app.css b/resources/css/app.css index 9d484db..aa17e81 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -7,6 +7,17 @@ @custom-variant dark (&:is(.dark *)); +@font-face { + font-family: 'IRANSansWeb'; + src: + url('/fonts/IRANSansWeb/IRANSansWeb.woff2') format('woff2'), + url('/fonts/IRANSansWeb/IRANSansWeb.woff') format('woff'), + url('/fonts/IRANSansWeb/IRANSansWeb.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + @theme { --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, @@ -61,6 +72,13 @@ --color-sidebar-ring: var(--sidebar-ring); } +html[dir='rtl'] { + --font-sans: + 'IRANSansWeb', ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; +} + :root { --background: oklch(0.982 0.012 315); --foreground: oklch(0.18 0.045 312); diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 504acab..308a0b9 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -1,10 +1,12 @@ -import { createInertiaApp } from '@inertiajs/react'; +import { createInertiaApp, router } from '@inertiajs/react'; import { Toaster } from '@/components/ui/sonner'; import { TooltipProvider } from '@/components/ui/tooltip'; import { initializeTheme } from '@/hooks/use-appearance'; import AppLayout from '@/layouts/app-layout'; import AuthLayout from '@/layouts/auth-layout'; import SettingsLayout from '@/layouts/settings/layout'; +import { applyDocumentLocalization } from '@/lib/localization'; +import type { Localization } from '@/types'; const appName = import.meta.env.VITE_APP_NAME || 'Nyone'; @@ -38,3 +40,15 @@ createInertiaApp({ // This will set light / dark mode on load... initializeTheme(); + +router.on('navigate', (event) => { + applyDocumentLocalization( + event.detail.page.props.localization as Localization | undefined, + ); +}); + +router.on('success', (event) => { + applyDocumentLocalization( + event.detail.page.props.localization as Localization | undefined, + ); +}); diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx index ace4b27..68ac31c 100644 --- a/resources/js/components/app-header.tsx +++ b/resources/js/components/app-header.tsx @@ -10,6 +10,7 @@ import { import AppLogo from '@/components/app-logo'; import AppLogoIcon from '@/components/app-logo-icon'; import { Breadcrumbs } from '@/components/breadcrumbs'; +import { LanguageSwitcher } from '@/components/language-switcher'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { @@ -54,7 +55,7 @@ const activeItemStyles = 'bg-accent text-accent-foreground'; export function AppHeader({ breadcrumbs = [] }: Props) { const page = usePage(); - const { auth } = page.props; + const { auth, localization } = page.props; const getInitials = useInitials(); const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl(); const { t } = useTranslation(); @@ -100,19 +101,19 @@ export function AppHeader({ breadcrumbs = [] }: Props) { {t('Navigation menu')} - +
@@ -122,7 +123,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) { {item.icon && ( @@ -142,15 +143,15 @@ export function AppHeader({ breadcrumbs = [] }: Props) { {/* Desktop Navigation */} -
+
- + {mainNavItems.map((item, index) => ( {item.icon && ( - + )} {t(item.title)} @@ -181,8 +182,9 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
-
-
+
+ +
-
+
-
+
Nyone diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx index 9d53d6c..c098a03 100644 --- a/resources/js/components/app-sidebar-header.tsx +++ b/resources/js/components/app-sidebar-header.tsx @@ -10,7 +10,7 @@ export function AppSidebarHeader({ return (
- +
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 601cd1e..14b0d6d 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,6 +1,7 @@ import { Link, usePage } from '@inertiajs/react'; import { LayoutGrid, LifeBuoy, Radio, Shield } from 'lucide-react'; import AppLogo from '@/components/app-logo'; +import { LanguageSwitcher } from '@/components/language-switcher'; import { NavMain } from '@/components/nav-main'; import { NavUser } from '@/components/nav-user'; import { @@ -19,7 +20,7 @@ import { index as supportIndex } from '@/routes/support'; import type { NavItem } from '@/types'; export function AppSidebar() { - const { auth } = usePage().props; + const { auth, localization } = usePage().props; const mainNavItems: NavItem[] = [ { title: 'Live', @@ -52,7 +53,11 @@ export function AppSidebar() { } return ( - + @@ -70,6 +75,7 @@ export function AppSidebar() { + diff --git a/resources/js/components/appearance-tabs.tsx b/resources/js/components/appearance-tabs.tsx index 70fca0e..644081c 100644 --- a/resources/js/components/appearance-tabs.tsx +++ b/resources/js/components/appearance-tabs.tsx @@ -38,8 +38,8 @@ export default function AppearanceToggleTab({ : 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60', )} > - - {t(label)} + + {t(label)} ))}
diff --git a/resources/js/components/language-switcher.tsx b/resources/js/components/language-switcher.tsx new file mode 100644 index 0000000..ee947a8 --- /dev/null +++ b/resources/js/components/language-switcher.tsx @@ -0,0 +1,224 @@ +import { router, usePage } from '@inertiajs/react'; +import { Check, Globe2, Languages } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { applyDocumentLocalization } from '@/lib/localization'; +import { useTranslation } from '@/lib/translations'; +import { cn } from '@/lib/utils'; +import { update as updateLanguage } from '@/routes/language'; +import type { Localization, LocaleOption } from '@/types'; + +type LanguageSwitcherProps = { + align?: 'center' | 'end' | 'start'; + className?: string; + variant?: 'compact' | 'settings' | 'sidebar'; +}; + +export function LanguageSwitcher({ + align = 'end', + className, + variant = 'compact', +}: LanguageSwitcherProps) { + const { localization } = usePage().props; + const { t } = useTranslation(); + const [pendingLocale, setPendingLocale] = useState(null); + const currentLocale = + localization.locales.find( + (locale) => locale.code === localization.locale, + ) ?? localization.locales[0]; + + if (!currentLocale) { + return null; + } + + const updateLocale = (locale: string): void => { + if (locale === localization.locale || pendingLocale) { + return; + } + + const nextLocale = localization.locales.find( + (option) => option.code === locale, + ); + + if (!nextLocale) { + return; + } + + setPendingLocale(locale); + applyDocumentLocalization({ + direction: nextLocale.direction, + locale: nextLocale.code, + }); + + router + .optimistic((props) => ({ + locale: nextLocale.code, + localization: { + ...(props as { localization: Localization }).localization, + direction: nextLocale.direction, + isRtl: nextLocale.direction === 'rtl', + locale: nextLocale.code, + }, + })) + .post( + updateLanguage.url(), + { locale }, + { + preserveScroll: true, + preserveState: false, + onCancel: () => applyDocumentLocalization(localization), + onError: () => applyDocumentLocalization(localization), + onFinish: () => setPendingLocale(null), + onNetworkError: () => + applyDocumentLocalization(localization), + onSuccess: (page) => + applyDocumentLocalization( + page.props.localization as Localization | undefined, + ), + }, + ); + }; + + if (variant === 'settings') { + return ( +
+
+ + + +
+

+ {t('Interface language')} +

+

+ {t('Choose the language used across Nyone.')} +

+
+
+ +
+ {localization.locales.map((locale) => ( + + ))} +
+
+ ); + } + + return ( + + + + + + + + {t('Language')} + + + {localization.locales.map((locale) => ( + updateLocale(locale.code)} + > + + + + {locale.nativeName} + + + {t(locale.name)} + + + {locale.code === localization.locale && ( + + )} + + ))} + + + ); +} + +type LanguageOptionButtonProps = { + isActive: boolean; + isPending: boolean; + locale: LocaleOption; + onSelect: (locale: string) => void; +}; + +function LanguageOptionButton({ + isActive, + isPending, + locale, + onSelect, +}: LanguageOptionButtonProps) { + const { t } = useTranslation(); + + return ( + + ); +} + +function LocaleBadge({ locale }: { locale: LocaleOption }) { + return ( + + {locale.code} + + ); +} diff --git a/resources/js/components/nav-user.tsx b/resources/js/components/nav-user.tsx index a407239..f16fd2a 100644 --- a/resources/js/components/nav-user.tsx +++ b/resources/js/components/nav-user.tsx @@ -35,7 +35,7 @@ export function NavUser() { data-test="sidebar-menu-button" > - +
-
+
@@ -110,7 +110,7 @@ function TwoFactorSetupStep({
-
+
{!manualSetupKey ? (
@@ -126,7 +126,7 @@ function TwoFactorSetupStep({ /> @@ -205,7 +205,7 @@ function TwoFactorVerificationStep({ />
-
+
{emojiPickerOpen && channel.is_live && ( -
+
{chatEmojis.map((emoji) => (
); diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index fc3543f..5fe7eeb 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -11,6 +11,7 @@ import { Video, } from 'lucide-react'; import { useMemo, useState } from 'react'; +import { LanguageSwitcher } from '@/components/language-switcher'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useTranslation } from '@/lib/translations'; @@ -96,7 +97,8 @@ export default function Welcome({ )} -
+
+ {auth.user ? ( <>
@@ -269,14 +271,14 @@ export default function Welcome({
- + setQuery(event.target.value) } placeholder={t('Search live channels')} - className="pl-9 sm:w-72" + className="ps-9 sm:w-72" />