implement localization
This commit is contained in:
29
app/Http/Controllers/LanguageController.php
Normal file
29
app/Http/Controllers/LanguageController.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\UpdateLanguageRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class LanguageController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the incoming request.
|
||||||
|
*/
|
||||||
|
public function __invoke(UpdateLanguageRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->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'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,19 +35,58 @@ class HandleInertiaRequests extends Middleware
|
|||||||
*/
|
*/
|
||||||
public function share(Request $request): array
|
public function share(Request $request): array
|
||||||
{
|
{
|
||||||
|
$fallbackLocale = (string) config('app.fallback_locale');
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
$locales = $this->locales();
|
||||||
|
$direction = $locales[$locale]['direction'] ?? 'ltr';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
],
|
],
|
||||||
'fallbackLocale' => (string) config('app.fallback_locale'),
|
'fallbackLocale' => $fallbackLocale,
|
||||||
'locale' => app()->getLocale(),
|
'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',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
'translations' => fn () => $this->translations(),
|
'translations' => fn () => $this->translations(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{name: string, native_name: string, direction: string}>
|
||||||
|
*/
|
||||||
|
private function locales(): array
|
||||||
|
{
|
||||||
|
$locales = config('localization.locales', []);
|
||||||
|
|
||||||
|
return is_array($locales) ? $locales : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{name: string, native_name: string, direction: string}> $locales
|
||||||
|
* @return array<int, array{code: string, name: string, nativeName: string, direction: string}>
|
||||||
|
*/
|
||||||
|
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<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
56
app/Http/Middleware/SetLocale.php
Normal file
56
app/Http/Middleware/SetLocale.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Illuminate\Support\Facades\Cookie;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SetLocale
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$cookieName = (string) config('localization.cookie', 'locale');
|
||||||
|
$supportedLocales = array_keys((array) config('localization.locales', []));
|
||||||
|
$cookieLocale = $request->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<int, string> $supportedLocales
|
||||||
|
*/
|
||||||
|
private function validLocale(mixed $locale, array $supportedLocales): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($locale)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($locale, $supportedLocales, true) ? $locale : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/UpdateLanguageRequest.php
Normal file
30
app/Http/Requests/UpdateLanguageRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateLanguageRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'locale' => ['required', 'string', Rule::in(array_keys((array) config('localization.locales', [])))],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
use App\Http\Middleware\EnsureUserIsAdmin;
|
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||||
use App\Http\Middleware\HandleAppearance;
|
use App\Http\Middleware\HandleAppearance;
|
||||||
use App\Http\Middleware\HandleInertiaRequests;
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
|
use App\Http\Middleware\SetLocale;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -24,6 +25,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
|
SetLocale::class,
|
||||||
HandleAppearance::class,
|
HandleAppearance::class,
|
||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
|
|||||||
50
config/localization.php
Normal file
50
config/localization.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Locale Preference Cookie
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This cookie stores the visitor's selected interface language. When it is
|
||||||
|
| missing or invalid, the application falls back to the default locale
|
||||||
|
| configured in config/app.php via APP_LOCALE.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cookie' => '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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -321,5 +321,13 @@
|
|||||||
"or, enter the code manually": "أو أدخل الرمز يدويًا",
|
"or, enter the code manually": "أو أدخل الرمز يدويًا",
|
||||||
"pending": "معلّق",
|
"pending": "معلّق",
|
||||||
"recording": "جارٍ التسجيل",
|
"recording": "جارٍ التسجيل",
|
||||||
|
"Arabic": "العربية",
|
||||||
|
"Change language": "تغيير اللغة",
|
||||||
|
"Choose the language used across Nyone.": "اختر اللغة المستخدمة في Nyone.",
|
||||||
|
"English": "الإنجليزية",
|
||||||
|
"Interface language": "لغة الواجهة",
|
||||||
|
"Language": "اللغة",
|
||||||
|
"Persian": "الفارسية",
|
||||||
|
"Spanish": "الإسبانية",
|
||||||
"unknown": "غير معروف"
|
"unknown": "غير معروف"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,5 +321,13 @@
|
|||||||
"or, enter the code manually": "or, enter the code manually",
|
"or, enter the code manually": "or, enter the code manually",
|
||||||
"pending": "pending",
|
"pending": "pending",
|
||||||
"recording": "recording",
|
"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"
|
"unknown": "unknown"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,5 +321,13 @@
|
|||||||
"or, enter the code manually": "o introduce el código manualmente",
|
"or, enter the code manually": "o introduce el código manualmente",
|
||||||
"pending": "pendiente",
|
"pending": "pendiente",
|
||||||
"recording": "grabando",
|
"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"
|
"unknown": "desconocido"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,5 +321,13 @@
|
|||||||
"or, enter the code manually": "یا کد را دستی وارد کنید",
|
"or, enter the code manually": "یا کد را دستی وارد کنید",
|
||||||
"pending": "در انتظار",
|
"pending": "در انتظار",
|
||||||
"recording": "در حال ضبط",
|
"recording": "در حال ضبط",
|
||||||
|
"Arabic": "عربی",
|
||||||
|
"Change language": "تغییر زبان",
|
||||||
|
"Choose the language used across Nyone.": "زبان استفادهشده در Nyone را انتخاب کنید.",
|
||||||
|
"English": "انگلیسی",
|
||||||
|
"Interface language": "زبان رابط کاربری",
|
||||||
|
"Language": "زبان",
|
||||||
|
"Persian": "فارسی",
|
||||||
|
"Spanish": "اسپانیایی",
|
||||||
"unknown": "نامشخص"
|
"unknown": "نامشخص"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@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 {
|
@theme {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
||||||
@@ -61,6 +72,13 @@
|
|||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--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 {
|
:root {
|
||||||
--background: oklch(0.982 0.012 315);
|
--background: oklch(0.982 0.012 315);
|
||||||
--foreground: oklch(0.18 0.045 312);
|
--foreground: oklch(0.18 0.045 312);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createInertiaApp } from '@inertiajs/react';
|
import { createInertiaApp, router } from '@inertiajs/react';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { initializeTheme } from '@/hooks/use-appearance';
|
import { initializeTheme } from '@/hooks/use-appearance';
|
||||||
import AppLayout from '@/layouts/app-layout';
|
import AppLayout from '@/layouts/app-layout';
|
||||||
import AuthLayout from '@/layouts/auth-layout';
|
import AuthLayout from '@/layouts/auth-layout';
|
||||||
import SettingsLayout from '@/layouts/settings/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';
|
const appName = import.meta.env.VITE_APP_NAME || 'Nyone';
|
||||||
|
|
||||||
@@ -38,3 +40,15 @@ createInertiaApp({
|
|||||||
|
|
||||||
// This will set light / dark mode on load...
|
// This will set light / dark mode on load...
|
||||||
initializeTheme();
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import AppLogo from '@/components/app-logo';
|
import AppLogo from '@/components/app-logo';
|
||||||
import AppLogoIcon from '@/components/app-logo-icon';
|
import AppLogoIcon from '@/components/app-logo-icon';
|
||||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||||
|
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -54,7 +55,7 @@ const activeItemStyles = 'bg-accent text-accent-foreground';
|
|||||||
|
|
||||||
export function AppHeader({ breadcrumbs = [] }: Props) {
|
export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const { auth } = page.props;
|
const { auth, localization } = page.props;
|
||||||
const getInitials = useInitials();
|
const getInitials = useInitials();
|
||||||
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -100,19 +101,19 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="mr-2 h-[34px] w-[34px]"
|
className="me-2 h-[34px] w-[34px]"
|
||||||
>
|
>
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="left"
|
side={localization.isRtl ? 'right' : 'left'}
|
||||||
className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar"
|
className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar"
|
||||||
>
|
>
|
||||||
<SheetTitle className="sr-only">
|
<SheetTitle className="sr-only">
|
||||||
{t('Navigation menu')}
|
{t('Navigation menu')}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetHeader className="flex justify-start text-left">
|
<SheetHeader className="flex justify-start text-start">
|
||||||
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
|
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
|
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
|
||||||
@@ -122,7 +123,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
|||||||
<Link
|
<Link
|
||||||
key={item.title}
|
key={item.title}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="flex items-center space-x-2 font-medium"
|
className="flex items-center gap-2 font-medium"
|
||||||
>
|
>
|
||||||
{item.icon && (
|
{item.icon && (
|
||||||
<item.icon className="h-5 w-5" />
|
<item.icon className="h-5 w-5" />
|
||||||
@@ -142,15 +143,15 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
|||||||
<Link
|
<Link
|
||||||
href={home()}
|
href={home()}
|
||||||
prefetch
|
prefetch
|
||||||
className="flex items-center space-x-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<AppLogo />
|
<AppLogo />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<div className="ml-6 hidden h-full items-center space-x-6 lg:flex">
|
<div className="ms-6 hidden h-full items-center gap-6 lg:flex">
|
||||||
<NavigationMenu className="flex h-full items-stretch">
|
<NavigationMenu className="flex h-full items-stretch">
|
||||||
<NavigationMenuList className="flex h-full items-stretch space-x-2">
|
<NavigationMenuList className="flex h-full items-stretch gap-2">
|
||||||
{mainNavItems.map((item, index) => (
|
{mainNavItems.map((item, index) => (
|
||||||
<NavigationMenuItem
|
<NavigationMenuItem
|
||||||
key={index}
|
key={index}
|
||||||
@@ -168,7 +169,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && (
|
{item.icon && (
|
||||||
<item.icon className="mr-2 h-4 w-4" />
|
<item.icon className="me-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{t(item.title)}
|
{t(item.title)}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -181,8 +182,9 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
|||||||
</NavigationMenu>
|
</NavigationMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center space-x-2">
|
<div className="ms-auto flex items-center gap-2">
|
||||||
<div className="relative flex items-center space-x-1">
|
<LanguageSwitcher className="hidden sm:inline-flex" />
|
||||||
|
<div className="relative flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -193,7 +195,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
|||||||
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
|
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="ml-1 hidden gap-1 lg:flex">
|
<div className="ms-1 hidden gap-1 lg:flex">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function AppLogo() {
|
|||||||
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground shadow-sm">
|
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground shadow-sm">
|
||||||
<AppLogoIcon className="size-5 fill-current" />
|
<AppLogoIcon className="size-5 fill-current" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
<div className="ms-1 grid flex-1 text-start text-sm">
|
||||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
<span className="mb-0.5 truncate leading-tight font-semibold">
|
||||||
Nyone
|
Nyone
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function AppSidebarHeader({
|
|||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/50 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/50 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ms-1" />
|
||||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
import { LayoutGrid, LifeBuoy, Radio, Shield } from 'lucide-react';
|
import { LayoutGrid, LifeBuoy, Radio, Shield } from 'lucide-react';
|
||||||
import AppLogo from '@/components/app-logo';
|
import AppLogo from '@/components/app-logo';
|
||||||
|
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||||
import { NavMain } from '@/components/nav-main';
|
import { NavMain } from '@/components/nav-main';
|
||||||
import { NavUser } from '@/components/nav-user';
|
import { NavUser } from '@/components/nav-user';
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +20,7 @@ import { index as supportIndex } from '@/routes/support';
|
|||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const { auth } = usePage().props;
|
const { auth, localization } = usePage().props;
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Live',
|
title: 'Live',
|
||||||
@@ -52,7 +53,11 @@ export function AppSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" variant="inset">
|
<Sidebar
|
||||||
|
collapsible="icon"
|
||||||
|
side={localization.isRtl ? 'right' : 'left'}
|
||||||
|
variant="inset"
|
||||||
|
>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
@@ -70,6 +75,7 @@ export function AppSidebar() {
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
<LanguageSwitcher variant="sidebar" align="start" />
|
||||||
<NavUser />
|
<NavUser />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|||||||
@@ -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',
|
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="-ml-1 h-4 w-4" />
|
<Icon className="-ms-1 h-4 w-4" />
|
||||||
<span className="ml-1.5 text-sm">{t(label)}</span>
|
<span className="ms-1.5 text-sm">{t(label)}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
224
resources/js/components/language-switcher.tsx
Normal file
224
resources/js/components/language-switcher.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<section className={cn('space-y-4', className)}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<Languages className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-base font-semibold">
|
||||||
|
{t('Interface language')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('Choose the language used across Nyone.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{localization.locales.map((locale) => (
|
||||||
|
<LanguageOptionButton
|
||||||
|
key={locale.code}
|
||||||
|
locale={locale}
|
||||||
|
isActive={locale.code === localization.locale}
|
||||||
|
isPending={pendingLocale === locale.code}
|
||||||
|
onSelect={updateLocale}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={variant === 'sidebar' ? 'ghost' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-9 gap-2 px-2.5',
|
||||||
|
variant === 'sidebar' &&
|
||||||
|
'w-full justify-start border-transparent bg-transparent text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={t('Change language')}
|
||||||
|
>
|
||||||
|
<Globe2 className="size-4" />
|
||||||
|
<span className="text-xs font-semibold tracking-normal">
|
||||||
|
{currentLocale.code.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align={align} className="w-56">
|
||||||
|
<DropdownMenuLabel className="flex items-center gap-2">
|
||||||
|
<Globe2 className="size-4 text-muted-foreground" />
|
||||||
|
{t('Language')}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{localization.locales.map((locale) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={locale.code}
|
||||||
|
className="items-center gap-3 p-2"
|
||||||
|
disabled={pendingLocale !== null}
|
||||||
|
onSelect={() => updateLocale(locale.code)}
|
||||||
|
>
|
||||||
|
<LocaleBadge locale={locale} />
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 text-start"
|
||||||
|
dir={locale.direction}
|
||||||
|
>
|
||||||
|
<span className="block truncate text-sm font-medium">
|
||||||
|
{locale.nativeName}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{t(locale.name)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{locale.code === localization.locale && (
|
||||||
|
<Check className="size-4 text-primary" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LanguageOptionButtonProps = {
|
||||||
|
isActive: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
locale: LocaleOption;
|
||||||
|
onSelect: (locale: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LanguageOptionButton({
|
||||||
|
isActive,
|
||||||
|
isPending,
|
||||||
|
locale,
|
||||||
|
onSelect,
|
||||||
|
}: LanguageOptionButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-20 items-center gap-3 rounded-md border p-3 text-start transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'border-primary bg-primary/10 text-foreground shadow-sm'
|
||||||
|
: 'border-border bg-background hover:border-primary/50 hover:bg-accent',
|
||||||
|
)}
|
||||||
|
dir={locale.direction}
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => onSelect(locale.code)}
|
||||||
|
>
|
||||||
|
<LocaleBadge locale={locale} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-semibold">
|
||||||
|
{locale.nativeName}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{t(locale.name)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{isActive && <Check className="size-4 shrink-0 text-primary" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocaleBadge({ locale }: { locale: LocaleOption }) {
|
||||||
|
return (
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground uppercase">
|
||||||
|
{locale.code}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export function NavUser() {
|
|||||||
data-test="sidebar-menu-button"
|
data-test="sidebar-menu-button"
|
||||||
>
|
>
|
||||||
<UserInfo user={auth.user} />
|
<UserInfo user={auth.user} />
|
||||||
<ChevronsUpDown className="ml-auto size-4" />
|
<ChevronsUpDown className="ms-auto size-4" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ export default function PasswordInput({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
className={cn('pr-10', className)}
|
className={cn('pe-10', className)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none"
|
className="absolute inset-y-0 end-0 flex items-center rounded-e-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none"
|
||||||
aria-label={t(showPassword ? 'Hide password' : 'Show password')}
|
aria-label={t(showPassword ? 'Hide password' : 'Show password')}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function GridScanIcon() {
|
|||||||
{Array.from({ length: 5 }, (_, i) => (
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
<div
|
<div
|
||||||
key={`col-${i + 1}`}
|
key={`col-${i + 1}`}
|
||||||
className="border-r border-border last:border-r-0"
|
className="border-e border-border last:border-e-0"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +97,7 @@ function TwoFactorSetupStep({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full space-x-5">
|
<div className="flex w-full gap-5">
|
||||||
<Button className="w-full" onClick={onNextStep}>
|
<Button className="w-full" onClick={onNextStep}>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -110,7 +110,7 @@ function TwoFactorSetupStep({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full space-x-2">
|
<div className="flex w-full gap-2">
|
||||||
<div className="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
|
<div className="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
|
||||||
{!manualSetupKey ? (
|
{!manualSetupKey ? (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-muted p-3">
|
<div className="flex h-full w-full items-center justify-center bg-muted p-3">
|
||||||
@@ -126,7 +126,7 @@ function TwoFactorSetupStep({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => copy(manualSetupKey)}
|
onClick={() => copy(manualSetupKey)}
|
||||||
className="border-l border-border px-3 hover:bg-muted"
|
className="border-s border-border px-3 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<IconComponent className="w-4" />
|
<IconComponent className="w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -205,7 +205,7 @@ function TwoFactorVerificationStep({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full space-x-5">
|
<div className="flex w-full gap-5">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function DialogContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 end-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">{t("Close")}</span>
|
<span className="sr-only">{t("Close")}</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
@@ -77,7 +77,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-start", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function DropdownMenuItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -90,13 +90,13 @@ function DropdownMenuCheckboxItem({
|
|||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
<span className="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
@@ -126,12 +126,12 @@ function DropdownMenuRadioItem({
|
|||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
<span className="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CircleIcon className="size-2 fill-current" />
|
<CircleIcon className="size-2 fill-current" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
@@ -153,7 +153,7 @@ function DropdownMenuLabel({
|
|||||||
data-slot="dropdown-menu-label"
|
data-slot="dropdown-menu-label"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
"px-2 py-1.5 text-sm font-medium data-[inset]:ps-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -182,7 +182,7 @@ function DropdownMenuShortcut({
|
|||||||
<span
|
<span
|
||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
"text-muted-foreground ms-auto text-xs tracking-widest",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -209,13 +209,13 @@ function DropdownMenuSubTrigger({
|
|||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:ps-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ms-auto size-4 rtl:rotate-180" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const InputOTPSlot = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
"relative flex h-9 w-9 items-center justify-center border-y border-e border-input text-sm shadow-sm transition-all first:rounded-s-md first:border-s last:rounded-e-md",
|
||||||
isActive && "z-10 ring-1 ring-ring",
|
isActive && "z-10 ring-1 ring-ring",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function NavigationMenuTrigger({
|
|||||||
>
|
>
|
||||||
{children}{" "}
|
{children}{" "}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
className="relative top-[1px] ms-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</NavigationMenuPrimitive.Trigger>
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
|||||||
@@ -112,14 +112,14 @@ function SelectItem({
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
data-slot="select-item-indicator"
|
data-slot="select-item-indicator"
|
||||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
className="absolute end-2 flex size-3.5 items-center justify-center"
|
||||||
>
|
>
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function SheetContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 end-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
<span className="sr-only">{t("Close")}</span>
|
<span className="sr-only">{t("Close")}</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||||||
data-slot="sidebar-inset"
|
data-slot="sidebar-inset"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background relative flex max-w-full min-h-svh flex-1 flex-col",
|
"bg-background relative flex max-w-full min-h-svh flex-1 flex-col",
|
||||||
"peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-0",
|
"peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -425,7 +425,7 @@ function SidebarGroupAction({
|
|||||||
data-slot="sidebar-group-action"
|
data-slot="sidebar-group-action"
|
||||||
data-sidebar="group-action"
|
data-sidebar="group-action"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
// Increases the hit area of the button on mobile.
|
// Increases the hit area of the button on mobile.
|
||||||
"after:absolute after:-inset-2 md:after:hidden",
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
@@ -473,7 +473,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
const sidebarMenuButtonVariants = cva(
|
||||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -560,7 +560,7 @@ function SidebarMenuAction({
|
|||||||
data-slot="sidebar-menu-action"
|
data-slot="sidebar-menu-action"
|
||||||
data-sidebar="menu-action"
|
data-sidebar="menu-action"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
// Increases the hit area of the button on mobile.
|
// Increases the hit area of the button on mobile.
|
||||||
"after:absolute after:-inset-2 md:after:hidden",
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
"peer-data-[size=sm]/menu-button:top-1",
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
@@ -585,7 +585,7 @@ function SidebarMenuBadge({
|
|||||||
data-slot="sidebar-menu-badge"
|
data-slot="sidebar-menu-badge"
|
||||||
data-sidebar="menu-badge"
|
data-sidebar="menu-badge"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
"text-sidebar-foreground pointer-events-none absolute end-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
"peer-data-[size=sm]/menu-button:top-1",
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
"peer-data-[size=default]/menu-button:top-1.5",
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
@@ -642,7 +642,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
|||||||
data-slot="sidebar-menu-sub"
|
data-slot="sidebar-menu-sub"
|
||||||
data-sidebar="menu-sub"
|
data-sidebar="menu-sub"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-s px-2.5 py-0.5",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function ToggleGroupItem({
|
|||||||
variant: context.variant || variant,
|
variant: context.variant || variant,
|
||||||
size: context.size || size,
|
size: context.size || size,
|
||||||
}),
|
}),
|
||||||
"min-w-0 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
"min-w-0 shrink-0 rounded-none shadow-none first:rounded-s-md last:rounded-e-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-s-0 data-[variant=outline]:first:border-s",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function UserInfo({
|
|||||||
{getInitials(user.name)}
|
{getInitials(user.name)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-start text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
<span className="truncate font-medium">{user.name}</span>
|
||||||
{showEmail && (
|
{showEmail && (
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function UserMenuContent({ user }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div className="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||||
<UserInfo user={user} showEmail={true} />
|
<UserInfo user={user} showEmail={true} />
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
@@ -42,7 +42,7 @@ export function UserMenuContent({ user }: Props) {
|
|||||||
prefetch
|
prefetch
|
||||||
onClick={cleanup}
|
onClick={cleanup}
|
||||||
>
|
>
|
||||||
<Settings className="mr-2" />
|
<Settings className="me-2" />
|
||||||
{t('Settings')}
|
{t('Settings')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -56,7 +56,7 @@ export function UserMenuContent({ user }: Props) {
|
|||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
data-test="logout-button"
|
data-test="logout-button"
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2" />
|
<LogOut className="me-2" />
|
||||||
{t('Log out')}
|
{t('Log out')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import AppLogoIcon from '@/components/app-logo-icon';
|
import AppLogoIcon from '@/components/app-logo-icon';
|
||||||
|
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||||
import { useTranslation } from '@/lib/translations';
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { home } from '@/routes';
|
import { home } from '@/routes';
|
||||||
import type { AuthLayoutProps } from '@/types';
|
import type { AuthLayoutProps } from '@/types';
|
||||||
@@ -12,7 +13,8 @@ export default function AuthSimpleLayout({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
<div className="relative flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||||
|
<LanguageSwitcher className="absolute end-4 top-4" />
|
||||||
<div className="w-full max-w-sm rounded-md border bg-card p-6 shadow-sm">
|
<div className="w-full max-w-sm rounded-md border bg-card p-6 shadow-sm">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ export default function AuthSplitLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
|
<div className="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
|
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-e">
|
||||||
<div className="absolute inset-0 bg-zinc-900" />
|
<div className="absolute inset-0 bg-zinc-900" />
|
||||||
<Link
|
<Link
|
||||||
href={home()}
|
href={home()}
|
||||||
className="relative z-20 flex items-center text-lg font-medium"
|
className="relative z-20 flex items-center text-lg font-medium"
|
||||||
>
|
>
|
||||||
<AppLogoIcon className="mr-2 size-8 fill-current text-white" />
|
<AppLogoIcon className="me-2 size-8 fill-current text-white" />
|
||||||
{name}
|
{name}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,7 +32,7 @@ export default function AuthSplitLayout({
|
|||||||
>
|
>
|
||||||
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
|
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col items-start gap-2 text-left sm:items-center sm:text-center">
|
<div className="flex flex-col items-start gap-2 text-start sm:items-center sm:text-center">
|
||||||
<h1 className="text-xl font-medium">
|
<h1 className="text-xl font-medium">
|
||||||
{t(title ?? '')}
|
{t(title ?? '')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
|
|||||||
<div className="flex flex-col gap-6 lg:flex-row">
|
<div className="flex flex-col gap-6 lg:flex-row">
|
||||||
<aside className="w-full max-w-xl lg:w-48">
|
<aside className="w-full max-w-xl lg:w-48">
|
||||||
<nav
|
<nav
|
||||||
className="flex flex-col space-y-1 space-x-0"
|
className="flex flex-col gap-1"
|
||||||
aria-label={t('Settings')}
|
aria-label={t('Settings')}
|
||||||
>
|
>
|
||||||
{sidebarNavItems.map((item, index) => (
|
{sidebarNavItems.map((item, index) => (
|
||||||
|
|||||||
17
resources/js/lib/localization.ts
Normal file
17
resources/js/lib/localization.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Localization, LocaleDirection } from '@/types';
|
||||||
|
|
||||||
|
type DocumentLocalization = {
|
||||||
|
direction: LocaleDirection;
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applyDocumentLocalization(
|
||||||
|
localization?: DocumentLocalization | Localization | null,
|
||||||
|
): void {
|
||||||
|
if (!localization || typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.lang = localization.locale.replaceAll('_', '-');
|
||||||
|
document.documentElement.dir = localization.direction;
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
|||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
<div className="space-x-1 text-center text-sm text-muted-foreground rtl:space-x-reverse">
|
||||||
<span>{t('Or, return to')}</span>
|
<span>{t('Or, return to')}</span>
|
||||||
<TextLink href={login()}>{t('log in')}</TextLink>
|
<TextLink href={login()}>{t('log in')}</TextLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function Login({
|
|||||||
{canResetPassword && (
|
{canResetPassword && (
|
||||||
<TextLink
|
<TextLink
|
||||||
href={request()}
|
href={request()}
|
||||||
className="ml-auto text-sm"
|
className="ms-auto text-sm"
|
||||||
tabIndex={5}
|
tabIndex={5}
|
||||||
>
|
>
|
||||||
{t('Forgot password?')}
|
{t('Forgot password?')}
|
||||||
@@ -76,7 +76,7 @@ export default function Login({
|
|||||||
<InputError message={errors.password} />
|
<InputError message={errors.password} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="remember"
|
id="remember"
|
||||||
name="remember"
|
name="remember"
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export default function ChannelShow({
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside className="hidden min-h-[480px] border-l bg-card xl:flex">
|
<aside className="hidden min-h-[480px] border-s bg-card xl:flex">
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
channel={channel}
|
channel={channel}
|
||||||
authUser={Boolean(auth.user)}
|
authUser={Boolean(auth.user)}
|
||||||
@@ -474,7 +474,7 @@ function ChatPanel({
|
|||||||
message.created_at,
|
message.created_at,
|
||||||
)}
|
)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-auto shrink-0 text-[11px] text-muted-foreground',
|
'ms-auto shrink-0 text-[11px] text-muted-foreground',
|
||||||
isChannelOwner &&
|
isChannelOwner &&
|
||||||
'text-primary/80',
|
'text-primary/80',
|
||||||
)}
|
)}
|
||||||
@@ -549,7 +549,7 @@ function ChatPanel({
|
|||||||
<SmilePlus className="size-4" />
|
<SmilePlus className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{emojiPickerOpen && channel.is_live && (
|
{emojiPickerOpen && channel.is_live && (
|
||||||
<div className="absolute right-0 bottom-full z-10 mb-2 grid w-52 grid-cols-4 gap-1 rounded-md border bg-popover p-2 text-popover-foreground shadow-md">
|
<div className="absolute end-0 bottom-full z-10 mb-2 grid w-52 grid-cols-4 gap-1 rounded-md border bg-popover p-2 text-popover-foreground shadow-md">
|
||||||
{chatEmojis.map((emoji) => (
|
{chatEmojis.map((emoji) => (
|
||||||
<button
|
<button
|
||||||
key={emoji}
|
key={emoji}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import AppearanceTabs from '@/components/appearance-tabs';
|
import AppearanceTabs from '@/components/appearance-tabs';
|
||||||
import Heading from '@/components/heading';
|
import Heading from '@/components/heading';
|
||||||
|
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||||
import { useTranslation } from '@/lib/translations';
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { edit as editAppearance } from '@/routes/appearance';
|
import { edit as editAppearance } from '@/routes/appearance';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export default function Appearance() {
|
|||||||
description="Update your account's appearance settings"
|
description="Update your account's appearance settings"
|
||||||
/>
|
/>
|
||||||
<AppearanceTabs />
|
<AppearanceTabs />
|
||||||
|
<LanguageSwitcher variant="settings" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useTranslation } from '@/lib/translations';
|
import { useTranslation } from '@/lib/translations';
|
||||||
@@ -96,7 +97,8 @@ export default function Welcome({
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ms-auto flex items-center gap-2">
|
||||||
|
<LanguageSwitcher />
|
||||||
{auth.user ? (
|
{auth.user ? (
|
||||||
<>
|
<>
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
@@ -182,9 +184,9 @@ export default function Welcome({
|
|||||||
count: featuredChannel.viewer_count,
|
count: featuredChannel.viewer_count,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 text-primary sm:ml-auto">
|
<span className="inline-flex shrink-0 items-center gap-1 text-primary sm:ms-auto">
|
||||||
{t('Watch')}
|
{t('Watch')}
|
||||||
<ArrowRight className="size-4" />
|
<ArrowRight className="size-4 rtl:rotate-180" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,14 +271,14 @@ export default function Welcome({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
<Search className="absolute start-3 top-2.5 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setQuery(event.target.value)
|
setQuery(event.target.value)
|
||||||
}
|
}
|
||||||
placeholder={t('Search live channels')}
|
placeholder={t('Search live channels')}
|
||||||
className="pl-9 sm:w-72"
|
className="ps-9 sm:w-72"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
@@ -399,7 +401,7 @@ function Thumbnail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute top-3 left-3">
|
<div className="absolute start-3 top-3">
|
||||||
<LiveBadge compact />
|
<LiveBadge compact />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
3
resources/js/types/global.d.ts
vendored
3
resources/js/types/global.d.ts
vendored
@@ -1,5 +1,5 @@
|
|||||||
import type { Auth } from '@/types/auth';
|
import type { Auth } from '@/types/auth';
|
||||||
import type { Translations } from '@/types/localization';
|
import type { Localization, Translations } from '@/types/localization';
|
||||||
|
|
||||||
declare module 'react' {
|
declare module 'react' {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
@@ -15,6 +15,7 @@ declare module '@inertiajs/core' {
|
|||||||
auth: Auth;
|
auth: Auth;
|
||||||
fallbackLocale: string;
|
fallbackLocale: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
localization: Localization;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
translations: Translations;
|
translations: Translations;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
@@ -4,3 +4,20 @@ export type TranslationReplacements = Record<
|
|||||||
string,
|
string,
|
||||||
boolean | null | number | string | undefined
|
boolean | null | number | string | undefined
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type LocaleDirection = 'ltr' | 'rtl';
|
||||||
|
|
||||||
|
export type LocaleOption = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
nativeName: string;
|
||||||
|
direction: LocaleDirection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Localization = {
|
||||||
|
locale: string;
|
||||||
|
fallbackLocale: string;
|
||||||
|
direction: LocaleDirection;
|
||||||
|
isRtl: boolean;
|
||||||
|
locales: LocaleOption[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
@php
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
$direction = data_get(config("localization.locales.{$locale}"), 'direction', 'ltr');
|
||||||
|
@endphp
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
<html lang="{{ str_replace('_', '-', $locale) }}" dir="{{ $direction }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ use App\Http\Controllers\ChatMessageController;
|
|||||||
use App\Http\Controllers\CreatorDashboardController;
|
use App\Http\Controllers\CreatorDashboardController;
|
||||||
use App\Http\Controllers\FollowController;
|
use App\Http\Controllers\FollowController;
|
||||||
use App\Http\Controllers\HomeController;
|
use App\Http\Controllers\HomeController;
|
||||||
|
use App\Http\Controllers\LanguageController;
|
||||||
use App\Http\Controllers\MediaMtxController;
|
use App\Http\Controllers\MediaMtxController;
|
||||||
use App\Http\Controllers\SupportAttachmentController;
|
use App\Http\Controllers\SupportAttachmentController;
|
||||||
use App\Http\Controllers\SupportConversationController;
|
use App\Http\Controllers\SupportConversationController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', HomeController::class)->name('home');
|
Route::get('/', HomeController::class)->name('home');
|
||||||
|
Route::post('language', LanguageController::class)->name('language.update');
|
||||||
Route::get('channels/{channel:slug}', [ChannelController::class, 'show'])->name('channels.show');
|
Route::get('channels/{channel:slug}', [ChannelController::class, 'show'])->name('channels.show');
|
||||||
|
|
||||||
Route::prefix('internal/mediamtx')->name('internal.mediamtx.')->group(function () {
|
Route::prefix('internal/mediamtx')->name('internal.mediamtx.')->group(function () {
|
||||||
|
|||||||
@@ -10,99 +10,113 @@ class LocalizationTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_inertia_responses_share_locale_and_translations(): void
|
public function test_inertia_responses_use_configured_default_locale_when_cookie_is_missing(): void
|
||||||
{
|
{
|
||||||
$this->get(route('home'))
|
config(['app.locale' => 'fa']);
|
||||||
->assertOk()
|
|
||||||
->assertInertia(fn (Assert $page) => $page
|
|
||||||
->component('welcome')
|
|
||||||
->where('locale', app()->getLocale())
|
|
||||||
->where('fallbackLocale', config('app.fallback_locale'))
|
|
||||||
->where('translations.Live', __('Live'))
|
|
||||||
->where('translations.Password', __('Password'))
|
|
||||||
->where('translations.Closed', __('Closed'))
|
|
||||||
->where('translations', fn ($translations) => $translations->get('Browse active channels by category or title.') === __('Browse active channels by category or title.'))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_inertia_responses_share_spanish_translations(): void
|
|
||||||
{
|
|
||||||
app()->setLocale('es');
|
|
||||||
|
|
||||||
$this->get(route('home'))
|
|
||||||
->assertOk()
|
|
||||||
->assertInertia(fn (Assert $page) => $page
|
|
||||||
->component('welcome')
|
|
||||||
->where('locale', 'es')
|
|
||||||
->where('fallbackLocale', config('app.fallback_locale'))
|
|
||||||
->where('translations.Live', 'En vivo')
|
|
||||||
->where('translations.Password', 'Contraseña')
|
|
||||||
->where('translations.Closed', 'Cerrado')
|
|
||||||
->where('translations', fn ($translations) => $translations->get('Browse active channels by category or title.') === 'Explora canales activos por categoría o título.')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_inertia_responses_share_persian_translations(): void
|
|
||||||
{
|
|
||||||
app()->setLocale('fa');
|
|
||||||
|
|
||||||
$this->get(route('home'))
|
$this->get(route('home'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
->assertSee('dir="rtl"', false)
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('welcome')
|
->component('welcome')
|
||||||
->where('locale', 'fa')
|
->where('locale', 'fa')
|
||||||
->where('fallbackLocale', config('app.fallback_locale'))
|
->where('fallbackLocale', config('app.fallback_locale'))
|
||||||
->where('translations.Live', 'زنده')
|
->where('localization.locale', 'fa')
|
||||||
->where('translations.Password', 'رمز عبور')
|
->where('localization.direction', 'rtl')
|
||||||
->where('translations.Closed', 'بسته')
|
->where('localization.isRtl', true)
|
||||||
->where('translations', fn ($translations) => $translations->get('Browse active channels by category or title.') === 'کانالهای فعال را بر اساس دستهبندی یا عنوان مرور کنید.')
|
->has('localization.locales', 4)
|
||||||
|
->where('translations.Live', __('Live', [], 'fa'))
|
||||||
|
->where('translations.Password', __('Password', [], 'fa'))
|
||||||
|
->where('translations.Language', __('Language', [], 'fa'))
|
||||||
|
->where('translations', fn ($translations) => $translations->get('Browse active channels by category or title.') === __('Browse active channels by category or title.', [], 'fa'))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_inertia_responses_share_arabic_translations(): void
|
public function test_inertia_responses_use_valid_language_cookie(): void
|
||||||
{
|
{
|
||||||
app()->setLocale('ar');
|
config(['app.locale' => 'fa']);
|
||||||
|
|
||||||
$this->get(route('home'))
|
$this->withCookie('locale', 'es')
|
||||||
|
->get(route('home'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
->assertSee('dir="ltr"', false)
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('welcome')
|
||||||
|
->where('locale', 'es')
|
||||||
|
->where('localization.locale', 'es')
|
||||||
|
->where('localization.direction', 'ltr')
|
||||||
|
->where('localization.isRtl', false)
|
||||||
|
->where('translations.Live', __('Live', [], 'es'))
|
||||||
|
->where('translations.Password', __('Password', [], 'es'))
|
||||||
|
->where('translations.Language', __('Language', [], 'es'))
|
||||||
|
->where('translations', fn ($translations) => $translations->get('Browse active channels by category or title.') === __('Browse active channels by category or title.', [], 'es'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rtl_language_cookie_shares_rtl_metadata(): void
|
||||||
|
{
|
||||||
|
config(['app.locale' => 'en']);
|
||||||
|
|
||||||
|
$this->withCookie('locale', 'ar')
|
||||||
|
->get(route('home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('lang="ar"', false)
|
||||||
|
->assertSee('dir="rtl"', false)
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('welcome')
|
->component('welcome')
|
||||||
->where('locale', 'ar')
|
->where('locale', 'ar')
|
||||||
->where('fallbackLocale', config('app.fallback_locale'))
|
->where('localization.locale', 'ar')
|
||||||
->where('translations.Live', 'مباشر')
|
->where('localization.direction', 'rtl')
|
||||||
->where('translations.Password', 'كلمة المرور')
|
->where('localization.isRtl', true)
|
||||||
->where('translations.Closed', 'مغلق')
|
->where('translations.Live', __('Live', [], 'ar'))
|
||||||
->where('translations', fn ($translations) => $translations->get('Browse active channels by category or title.') === 'تصفح القنوات النشطة حسب الفئة أو العنوان.')
|
->where('translations.Password', __('Password', [], 'ar'))
|
||||||
|
->where('translations.Language', __('Language', [], 'ar'))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_spanish_backend_translations_are_available(): void
|
public function test_invalid_language_cookie_falls_back_to_configured_locale_and_expires_cookie(): void
|
||||||
{
|
{
|
||||||
app()->setLocale('es');
|
config(['app.locale' => 'fa']);
|
||||||
|
|
||||||
$this->assertSame('Estas credenciales no coinciden con nuestros registros.', __('auth.failed'));
|
$this->withCookie('locale', 'invalid')
|
||||||
$this->assertSame('Tu contraseña ha sido restablecida.', __('passwords.reset'));
|
->get(route('home'))
|
||||||
$this->assertSame('« Anterior', __('pagination.previous'));
|
->assertOk()
|
||||||
$this->assertSame('El campo email es obligatorio.', __('validation.required', ['attribute' => 'email']));
|
->assertCookieExpired('locale')
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('welcome')
|
||||||
|
->where('locale', 'fa')
|
||||||
|
->where('localization.locale', 'fa')
|
||||||
|
->where('localization.direction', 'rtl')
|
||||||
|
->where('localization.isRtl', true)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_persian_backend_translations_are_available(): void
|
public function test_user_can_change_language_preference(): void
|
||||||
{
|
{
|
||||||
app()->setLocale('fa');
|
$this->from(route('home'))
|
||||||
|
->post(route('language.update'), ['locale' => 'es'])
|
||||||
$this->assertSame('این اطلاعات با سوابق ما مطابقت ندارد.', __('auth.failed'));
|
->assertRedirect(route('home'))
|
||||||
$this->assertSame('رمز عبور شما بازنشانی شد.', __('passwords.reset'));
|
->assertCookie('locale', 'es');
|
||||||
$this->assertSame('« قبلی', __('pagination.previous'));
|
|
||||||
$this->assertSame('فیلد email الزامی است.', __('validation.required', ['attribute' => 'email']));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_arabic_backend_translations_are_available(): void
|
public function test_language_preference_must_be_supported(): void
|
||||||
{
|
{
|
||||||
app()->setLocale('ar');
|
$this->from(route('home'))
|
||||||
|
->post(route('language.update'), ['locale' => 'invalid'])
|
||||||
|
->assertRedirect(route('home'))
|
||||||
|
->assertSessionHasErrors('locale')
|
||||||
|
->assertCookieMissing('locale');
|
||||||
|
}
|
||||||
|
|
||||||
$this->assertSame('بيانات الاعتماد هذه لا تطابق سجلاتنا.', __('auth.failed'));
|
public function test_backend_translation_files_are_available(): void
|
||||||
$this->assertSame('تمت إعادة تعيين كلمة المرور.', __('passwords.reset'));
|
{
|
||||||
$this->assertSame('« السابق', __('pagination.previous'));
|
foreach (['en', 'es', 'fa', 'ar'] as $locale) {
|
||||||
$this->assertSame('حقل email مطلوب.', __('validation.required', ['attribute' => 'email']));
|
app()->setLocale($locale);
|
||||||
|
|
||||||
|
$this->assertNotSame('auth.failed', __('auth.failed'));
|
||||||
|
$this->assertNotSame('passwords.reset', __('passwords.reset'));
|
||||||
|
$this->assertNotSame('pagination.previous', __('pagination.previous'));
|
||||||
|
$this->assertNotSame('validation.required', __('validation.required', ['attribute' => 'email']));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user