implement localization

This commit is contained in:
2026-05-18 05:47:02 +03:30
parent e4ee983393
commit 774e2043c2
45 changed files with 704 additions and 140 deletions

View File

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

View File

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

View File

@@ -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) {
<Button
variant="ghost"
size="icon"
className="mr-2 h-[34px] w-[34px]"
className="me-2 h-[34px] w-[34px]"
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
side={localization.isRtl ? 'right' : 'left'}
className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar"
>
<SheetTitle className="sr-only">
{t('Navigation menu')}
</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" />
</SheetHeader>
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
@@ -122,7 +123,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
<Link
key={item.title}
href={item.href}
className="flex items-center space-x-2 font-medium"
className="flex items-center gap-2 font-medium"
>
{item.icon && (
<item.icon className="h-5 w-5" />
@@ -142,15 +143,15 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
<Link
href={home()}
prefetch
className="flex items-center space-x-2"
className="flex items-center gap-2"
>
<AppLogo />
</Link>
{/* 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">
<NavigationMenuList className="flex h-full items-stretch space-x-2">
<NavigationMenuList className="flex h-full items-stretch gap-2">
{mainNavItems.map((item, index) => (
<NavigationMenuItem
key={index}
@@ -168,7 +169,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
)}
>
{item.icon && (
<item.icon className="mr-2 h-4 w-4" />
<item.icon className="me-2 h-4 w-4" />
)}
{t(item.title)}
</Link>
@@ -181,8 +182,9 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
</NavigationMenu>
</div>
<div className="ml-auto flex items-center space-x-2">
<div className="relative flex items-center space-x-1">
<div className="ms-auto flex items-center gap-2">
<LanguageSwitcher className="hidden sm:inline-flex" />
<div className="relative flex items-center gap-1">
<Button
asChild
variant="ghost"
@@ -193,7 +195,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
</Link>
</Button>
<div className="ml-1 hidden gap-1 lg:flex">
<div className="ms-1 hidden gap-1 lg:flex">
<Tooltip>
<TooltipTrigger asChild>
<Link

View File

@@ -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">
<AppLogoIcon className="size-5 fill-current" />
</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">
Nyone
</span>

View File

@@ -10,7 +10,7 @@ export function AppSidebarHeader({
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">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<SidebarTrigger className="-ms-1" />
<Breadcrumbs breadcrumbs={breadcrumbs} />
</div>
</header>

View File

@@ -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 (
<Sidebar collapsible="icon" variant="inset">
<Sidebar
collapsible="icon"
side={localization.isRtl ? 'right' : 'left'}
variant="inset"
>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -70,6 +75,7 @@ export function AppSidebar() {
</SidebarContent>
<SidebarFooter>
<LanguageSwitcher variant="sidebar" align="start" />
<NavUser />
</SidebarFooter>
</Sidebar>

View File

@@ -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',
)}
>
<Icon className="-ml-1 h-4 w-4" />
<span className="ml-1.5 text-sm">{t(label)}</span>
<Icon className="-ms-1 h-4 w-4" />
<span className="ms-1.5 text-sm">{t(label)}</span>
</button>
))}
</div>

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

View File

@@ -35,7 +35,7 @@ export function NavUser() {
data-test="sidebar-menu-button"
>
<UserInfo user={auth.user} />
<ChevronsUpDown className="ml-auto size-4" />
<ChevronsUpDown className="ms-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent

View File

@@ -17,14 +17,14 @@ export default function PasswordInput({
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
className={cn('pr-10', className)}
className={cn('pe-10', className)}
ref={ref}
{...props}
/>
<button
type="button"
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')}
tabIndex={-1}
>

View File

@@ -32,7 +32,7 @@ function GridScanIcon() {
{Array.from({ length: 5 }, (_, i) => (
<div
key={`col-${i + 1}`}
className="border-r border-border last:border-r-0"
className="border-e border-border last:border-e-0"
/>
))}
</div>
@@ -97,7 +97,7 @@ function TwoFactorSetupStep({
</div>
</div>
<div className="flex w-full space-x-5">
<div className="flex w-full gap-5">
<Button className="w-full" onClick={onNextStep}>
{buttonText}
</Button>
@@ -110,7 +110,7 @@ function TwoFactorSetupStep({
</span>
</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">
{!manualSetupKey ? (
<div className="flex h-full w-full items-center justify-center bg-muted p-3">
@@ -126,7 +126,7 @@ function TwoFactorSetupStep({
/>
<button
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" />
</button>
@@ -205,7 +205,7 @@ function TwoFactorVerificationStep({
/>
</div>
<div className="flex w-full space-x-5">
<div className="flex w-full gap-5">
<Button
type="button"
variant="outline"

View File

@@ -64,7 +64,7 @@ function DialogContent({
{...props}
>
{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 />
<span className="sr-only">{t("Close")}</span>
</DialogPrimitive.Close>
@@ -77,7 +77,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
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}
/>
)

View File

@@ -72,7 +72,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
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
)}
{...props}
@@ -90,13 +90,13 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
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
)}
checked={checked}
{...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>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -126,12 +126,12 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
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
)}
{...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>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -153,7 +153,7 @@ function DropdownMenuLabel({
data-slot="dropdown-menu-label"
data-inset={inset}
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
)}
{...props}
@@ -182,7 +182,7 @@ function DropdownMenuShortcut({
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
"text-muted-foreground ms-auto text-xs tracking-widest",
className
)}
{...props}
@@ -209,13 +209,13 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
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
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
<ChevronRightIcon className="ms-auto size-4 rtl:rotate-180" />
</DropdownMenuPrimitive.SubTrigger>
)
}

View File

@@ -39,7 +39,7 @@ const InputOTPSlot = React.forwardRef<
<div
ref={ref}
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",
className
)}

View File

@@ -75,7 +75,7 @@ function NavigationMenuTrigger({
>
{children}{" "}
<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"
/>
</NavigationMenuPrimitive.Trigger>

View File

@@ -112,14 +112,14 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
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
)}
{...props}
>
<span
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>
<CheckIcon className="size-4" />

View File

@@ -73,7 +73,7 @@ function SheetContent({
{...props}
>
{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" />
<span className="sr-only">{t("Close")}</span>
</SheetPrimitive.Close>

View File

@@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
data-slot="sidebar-inset"
className={cn(
"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
)}
{...props}
@@ -425,7 +425,7 @@ function SidebarGroupAction({
data-slot="sidebar-group-action"
data-sidebar="group-action"
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.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
@@ -473,7 +473,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
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: {
variant: {
@@ -560,7 +560,7 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
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.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
@@ -585,7 +585,7 @@ function SidebarMenuBadge({
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
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-data-[size=sm]/menu-button:top-1",
"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-sidebar="menu-sub"
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",
className
)}

View File

@@ -58,7 +58,7 @@ function ToggleGroupItem({
variant: context.variant || variant,
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
)}
{...props}

View File

@@ -19,7 +19,7 @@ export function UserInfo({
{getInitials(user.name)}
</AvatarFallback>
</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>
{showEmail && (
<span className="truncate text-xs text-muted-foreground">

View File

@@ -29,7 +29,7 @@ export function UserMenuContent({ user }: Props) {
return (
<>
<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} />
</div>
</DropdownMenuLabel>
@@ -42,7 +42,7 @@ export function UserMenuContent({ user }: Props) {
prefetch
onClick={cleanup}
>
<Settings className="mr-2" />
<Settings className="me-2" />
{t('Settings')}
</Link>
</DropdownMenuItem>
@@ -56,7 +56,7 @@ export function UserMenuContent({ user }: Props) {
onClick={handleLogout}
data-test="logout-button"
>
<LogOut className="mr-2" />
<LogOut className="me-2" />
{t('Log out')}
</Link>
</DropdownMenuItem>

View File

@@ -1,5 +1,6 @@
import { Link } from '@inertiajs/react';
import AppLogoIcon from '@/components/app-logo-icon';
import { LanguageSwitcher } from '@/components/language-switcher';
import { useTranslation } from '@/lib/translations';
import { home } from '@/routes';
import type { AuthLayoutProps } from '@/types';
@@ -12,7 +13,8 @@ export default function AuthSimpleLayout({
const { t } = useTranslation();
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="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4">

View File

@@ -14,13 +14,13 @@ export default function AuthSplitLayout({
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 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" />
<Link
href={home()}
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}
</Link>
</div>
@@ -32,7 +32,7 @@ export default function AuthSplitLayout({
>
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
</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">
{t(title ?? '')}
</h1>

View File

@@ -44,7 +44,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
<div className="flex flex-col gap-6 lg:flex-row">
<aside className="w-full max-w-xl lg:w-48">
<nav
className="flex flex-col space-y-1 space-x-0"
className="flex flex-col gap-1"
aria-label={t('Settings')}
>
{sidebarNavItems.map((item, index) => (

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

View File

@@ -57,7 +57,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
)}
</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>
<TextLink href={login()}>{t('log in')}</TextLink>
</div>

View File

@@ -58,7 +58,7 @@ export default function Login({
{canResetPassword && (
<TextLink
href={request()}
className="ml-auto text-sm"
className="ms-auto text-sm"
tabIndex={5}
>
{t('Forgot password?')}
@@ -76,7 +76,7 @@ export default function Login({
<InputError message={errors.password} />
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center gap-3">
<Checkbox
id="remember"
name="remember"

View File

@@ -275,7 +275,7 @@ export default function ChannelShow({
</section>
</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
channel={channel}
authUser={Boolean(auth.user)}
@@ -474,7 +474,7 @@ function ChatPanel({
message.created_at,
)}
className={cn(
'ml-auto shrink-0 text-[11px] text-muted-foreground',
'ms-auto shrink-0 text-[11px] text-muted-foreground',
isChannelOwner &&
'text-primary/80',
)}
@@ -549,7 +549,7 @@ function ChatPanel({
<SmilePlus className="size-4" />
</Button>
{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) => (
<button
key={emoji}

View File

@@ -1,6 +1,7 @@
import { Head } from '@inertiajs/react';
import AppearanceTabs from '@/components/appearance-tabs';
import Heading from '@/components/heading';
import { LanguageSwitcher } from '@/components/language-switcher';
import { useTranslation } from '@/lib/translations';
import { edit as editAppearance } from '@/routes/appearance';
@@ -20,6 +21,7 @@ export default function Appearance() {
description="Update your account's appearance settings"
/>
<AppearanceTabs />
<LanguageSwitcher variant="settings" />
</div>
</>
);

View File

@@ -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({
)}
</nav>
<div className="ml-auto flex items-center gap-2">
<div className="ms-auto flex items-center gap-2">
<LanguageSwitcher />
{auth.user ? (
<>
<Button asChild size="sm">
@@ -182,9 +184,9 @@ export default function Welcome({
count: featuredChannel.viewer_count,
})}
</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')}
<ArrowRight className="size-4" />
<ArrowRight className="size-4 rtl:rotate-180" />
</span>
</div>
</div>
@@ -269,14 +271,14 @@ export default function Welcome({
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<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
value={query}
onChange={(event) =>
setQuery(event.target.value)
}
placeholder={t('Search live channels')}
className="pl-9 sm:w-72"
className="ps-9 sm:w-72"
/>
</div>
<select
@@ -399,7 +401,7 @@ function Thumbnail({
</div>
</div>
)}
<div className="absolute top-3 left-3">
<div className="absolute start-3 top-3">
<LiveBadge compact />
</div>
</div>

View File

@@ -1,5 +1,5 @@
import type { Auth } from '@/types/auth';
import type { Translations } from '@/types/localization';
import type { Localization, Translations } from '@/types/localization';
declare module 'react' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -15,6 +15,7 @@ declare module '@inertiajs/core' {
auth: Auth;
fallbackLocale: string;
locale: string;
localization: Localization;
sidebarOpen: boolean;
translations: Translations;
[key: string]: unknown;

View File

@@ -4,3 +4,20 @@ export type TranslationReplacements = Record<
string,
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[];
};

View File

@@ -1,5 +1,10 @@
@php
$locale = app()->getLocale();
$direction = data_get(config("localization.locales.{$locale}"), 'direction', 'ltr');
@endphp
<!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>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">