feat(i18n): add language switcher in top-right corner for multi-language support #9

This commit is contained in:
rustmailer
2025-11-24 21:51:16 +08:00
parent 5ebc7394d0
commit ea81d0298b
119 changed files with 15277 additions and 1662 deletions

View File

@@ -21,16 +21,18 @@ import { Card } from "@/components/ui/card";
import { FixedHeader } from "./layout/fixed-header";
import { Main } from "./layout/main";
import Logo from '@/assets/logo.svg'
const docsOptions = [
{ name: "Swagger UI", path: "/api-docs/swagger" },
{ name: "ReDoc", path: "/api-docs/redoc" },
{ name: "OpenAPI Explorer", path: "/api-docs/explorer" },
{ name: "Scalar", path: "/api-docs/scalar" },
{ name: "Download Spec YAML", path: "/api-docs/spec.yaml" }
];
import { useTranslation } from 'react-i18next'
export default function APIDocs() {
const { t } = useTranslation()
const docsOptions = [
{ name: t('apiDocs.swaggerUI'), path: "/api-docs/swagger" },
{ name: t('apiDocs.reDoc'), path: "/api-docs/redoc" },
{ name: t('apiDocs.openAPIExplorer'), path: "/api-docs/explorer" },
{ name: t('apiDocs.scalar'), path: "/api-docs/scalar" },
{ name: t('apiDocs.downloadSpecYAML'), path: "/api-docs/spec.yaml" }
];
const handleCardClick = (path: string) => {
// Open in new tab
window.open(path, '_blank', 'noopener,noreferrer');
@@ -43,9 +45,9 @@ export default function APIDocs() {
<Main>
<div className='mb-2 flex items-center justify-between space-y-2 flex-wrap gap-x-4'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>API Documentation</h2>
<h2 className='text-2xl font-bold tracking-tight'>{t('navigation.apiDocs')}</h2>
<p className='text-muted-foreground'>
Choose your preferred API documentation type
{t('apiDocs.choosePreferredType')}
</p>
</div>
</div>
@@ -62,7 +64,7 @@ export default function APIDocs() {
<img
src={Logo}
className="max-h-[66px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="RustMailer Logo"
alt="Bichon Logo"
/>
<h3 className="text-sm font-medium">{option.name}</h3>
</div>

View File

@@ -35,13 +35,16 @@ import {
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { sidebarData } from './layout/data/sidebar-data'
import { useSidebarData } from './layout/data/sidebar-data'
import { ScrollArea } from './ui/scroll-area'
import { useTranslation } from 'react-i18next'
export function CommandMenu() {
const navigate = useNavigate()
const { setTheme } = useTheme()
const { open, setOpen } = useSearch()
const sidebarData = useSidebarData()
const { t } = useTranslation()
const runCommand = React.useCallback(
(command: () => unknown) => {
@@ -53,10 +56,10 @@ export function CommandMenu() {
return (
<CommandDialog modal open={open} onOpenChange={setOpen}>
<CommandInput placeholder='Type a command or search...' />
<CommandInput placeholder={t('command.typeCommandOrSearch')} />
<CommandList>
<ScrollArea type='hover' className='h-72 pr-1'>
<CommandEmpty>No results found.</CommandEmpty>
<CommandEmpty>{t('command.noResultsFound')}</CommandEmpty>
{sidebarData.navGroups.map((group) => (
<CommandGroup key={group.title} heading={group.title}>
{group.items.map((navItem, i) => {
@@ -94,13 +97,13 @@ export function CommandMenu() {
</CommandGroup>
))}
<CommandSeparator />
<CommandGroup heading='Theme'>
<CommandGroup heading={t('command.theme')}>
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
<IconSun /> <span>Light</span>
<IconSun /> <span>{t('command.light')}</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
<IconMoon className='scale-90' />
<span>Dark</span>
<span>{t('command.dark')}</span>
</CommandItem>
</CommandGroup>
</ScrollArea>

View File

@@ -28,6 +28,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useTranslation } from 'react-i18next'
interface ConfirmDialogProps {
open: boolean
@@ -45,6 +46,7 @@ interface ConfirmDialogProps {
}
export function ConfirmDialog(props: ConfirmDialogProps) {
const { t } = useTranslation()
const {
title,
desc,
@@ -70,14 +72,14 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
{children}
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>
{cancelBtnText ?? 'Cancel'}
{cancelBtnText ?? t('common.cancel')}
</AlertDialogCancel>
<Button
variant={destructive ? 'destructive' : 'default'}
onClick={handleConfirm}
disabled={disabled || isLoading}
>
{confirmText ?? 'Continue'}
{confirmText ?? t('dialogs.continue')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -0,0 +1,77 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Languages } from 'lucide-react'
const LANGUAGES = [
{ code: 'ar', label: 'العربية' },
{ code: 'da', label: 'Dansk' },
{ code: 'de', label: 'Deutsch' },
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
{ code: 'fi', label: 'Suomi' },
{ code: 'fr', label: 'Français' },
{ code: 'it', label: 'Italiano' },
{ code: 'jp', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'nl', label: 'Nederlands' },
{ code: 'no', label: 'Norsk' },
{ code: 'pt', label: 'Português' },
{ code: 'ru', label: 'Русский' },
{ code: 'sv', label: 'Svenska' },
{ code: 'zh', label: '中文' },
{ code: 'zh-tw', label: '繁體中文' },
]
export function LanguageSwitch() {
const { i18n } = useTranslation()
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Languages className="h-4 w-4" />
<span className="sr-only">Change language</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{LANGUAGES.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => changeLanguage(lang.code)}
>
{lang.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -26,10 +26,11 @@ import {
} from '@/components/ui/sidebar'
import { NavGroup } from '@/components/layout/nav-group'
import Logo from '@/assets/logo.svg'
import { sidebarData } from './data/sidebar-data'
import { useSidebarData } from './data/sidebar-data'
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { open } = useSidebar();
const sidebarData = useSidebarData();
return (
<Sidebar collapsible='icon' variant='sidebar' {...props}>
<SidebarHeader>

View File

@@ -25,68 +25,73 @@ import {
} from '@tabler/icons-react'
import { IdCard, Inbox, Mailbox, Search } from 'lucide-react'
import { type SidebarData } from '../types'
import { useTranslation } from 'react-i18next'
export const sidebarData: SidebarData = {
navGroups: [
{
title: 'General',
items: [
{
title: 'Dashboard',
url: '/',
icon: IconLayoutDashboard,
}
],
},
{
title: 'Accounts',
items: [
{
title: 'Accounts',
url: '/accounts',
icon: Inbox,
},
{
title: 'MailBox',
url: '/mailboxes',
icon: Mailbox,
},
{
title: 'Search',
url: '/search',
icon: Search,
}
],
},
{
title: 'Auth',
items: [
{
title: 'OAuth2',
url: '/oauth2',
icon: IdCard,
},
{
title: 'Access Tokens',
url: '/access-tokens',
icon: IconLockAccess,
}
]
},
{
title: 'Other',
items: [
{
title: 'Settings',
url: '/settings',
icon: IconSettings,
},
{
title: 'API Documentation',
url: '/api-docs',
icon: IconHelp,
},
],
},
],
export function useSidebarData(): SidebarData {
const { t } = useTranslation()
return {
navGroups: [
{
title: t('navigation.general'),
items: [
{
title: t('navigation.dashboard'),
url: '/',
icon: IconLayoutDashboard,
}
],
},
{
title: t('navigation.accounts'),
items: [
{
title: t('navigation.accounts'),
url: '/accounts',
icon: Inbox,
},
{
title: t('navigation.mailbox'),
url: '/mailboxes',
icon: Mailbox,
},
{
title: t('common.search'),
url: '/search',
icon: Search,
}
],
},
{
title: t('navigation.auth'),
items: [
{
title: t('navigation.oauth2'),
url: '/oauth2',
icon: IdCard,
},
{
title: t('navigation.accessTokens'),
url: '/access-tokens',
icon: IconLockAccess,
}
]
},
{
title: t('navigation.other'),
items: [
{
title: t('navigation.settings'),
url: '/settings',
icon: IconSettings,
},
{
title: t('navigation.apiDocs'),
url: '/api-docs',
icon: IconHelp,
},
],
},
],
}
}

View File

@@ -22,6 +22,7 @@ import { ProfileDropdown } from "../profile-dropdown";
import { Header } from "./header";
import { NotificationPopover } from "./notification";
import { GithubLinkButton } from "./github";
import { LanguageSwitch } from "../language-switch";
export const FixedHeader = () => {
return (
@@ -30,6 +31,7 @@ export const FixedHeader = () => {
<div className='ml-auto flex items-center space-x-4'>
<NotificationPopover />
<GithubLinkButton />
<LanguageSwitch />
<ThemeSwitch />
<ProfileDropdown />
</div>

View File

@@ -45,6 +45,7 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { useTranslation } from "react-i18next";
/**
* Variants for the multi-select component to handle different styles.
@@ -75,7 +76,7 @@ const multiSelectVariants = cva(
*/
interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> {
VariantProps<typeof multiSelectVariants> {
/**
* An array of option objects to be displayed in the multi-select component.
* Each option object has a label, value, and an optional icon.
@@ -156,6 +157,7 @@ export const MultiSelect = React.forwardRef<
},
ref
) => {
const { t } = useTranslation()
const [selectedValues, setSelectedValues] =
React.useState<string[]>(defaultValue);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
@@ -308,7 +310,7 @@ export const MultiSelect = React.forwardRef<
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandEmpty>{t('common.table.noResults')}</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"

View File

@@ -29,6 +29,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface PaginationProps {
totalItems: number
@@ -47,6 +48,7 @@ export function EnvelopeListPagination({
setPageIndex,
setPageSize,
}: PaginationProps) {
const { t } = useTranslation()
const pageCount = Math.ceil(totalItems / pageSize)
const handlePageSizeChange = (value: string) => {
@@ -67,11 +69,11 @@ export function EnvelopeListPagination({
return (
<div className='flex items-center justify-between space-x-2 overflow-auto px-2'>
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{totalItems} total items
{totalItems} {t("table.results")}
</div>
<div className='flex items-center sm:space-x-6 lg:space-x-4'>
<div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
<p className='hidden text-sm font-medium sm:block'>{t("table.rowsPerPage")}</p>
<Select
value={`${pageSize}`}
onValueChange={handlePageSizeChange}
@@ -89,7 +91,7 @@ export function EnvelopeListPagination({
</Select>
</div>
<div className='flex items-center justify-center text-sm font-medium'>
Page {pageIndex + 1} of {pageCount}
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
</div>
<div className='flex items-center space-x-2'>
<Button
@@ -98,7 +100,7 @@ export function EnvelopeListPagination({
onClick={goToPreviousPage}
disabled={pageIndex === 0}
>
<span className='sr-only'>Go to previous page</span>
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -107,7 +109,7 @@ export function EnvelopeListPagination({
onClick={goToNextPage}
disabled={!hasNextPage()}
>
<span className='sr-only'>Go to next page</span>
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
</div>

View File

@@ -30,9 +30,11 @@ import { LogoutConfirmDialog } from '@/features/auth/sign-in/components/logout';
import { resetAccessToken } from '@/stores/authStore';
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
export function ProfileDropdown() {
const navigate = useNavigate()
const { t } = useTranslation()
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false)
const handleLogout = () => {
@@ -52,7 +54,7 @@ export function ProfileDropdown() {
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' forceMount>
<DropdownMenuItem onClick={() => setIsLogoutDialogOpen(true)}>
Log out
{t('auth.logout')}
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>