mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
feat(i18n): add language switcher in top-right corner for multi-language support #9
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
77
web/src/components/language-switch.tsx
Normal file
77
web/src/components/language-switch.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user