Files
nyone/resources/js/components/language-switcher.tsx
2026-05-18 05:47:02 +03:30

225 lines
8.1 KiB
TypeScript

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>
);
}