feat(i18n): Implement internationalization for date distance

This commit is contained in:
rustmailer
2025-12-03 09:23:16 +08:00
parent 8f7244ccb9
commit 9ba56ca5bb
8 changed files with 121 additions and 61 deletions
@@ -23,11 +23,11 @@ import LongText from '@/components/long-text'
import { AccessToken } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format, formatDistanceToNow } from 'date-fns'
import { format, formatDistanceToNow, Locale } from 'date-fns'
import { AccountCellAction } from './account-action'
import { AclCellAction } from './acl-action'
export const getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[] => [
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
{
accessorKey: 'token',
header: ({ column }) => (
@@ -114,7 +114,7 @@ export const getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[]
if (last_access_at === 0) {
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
}
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true });
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
return <LongText className='max-w-40'>{result}</LongText>;
},
meta: { className: 'w-40' },
+41 -38
View File
@@ -38,9 +38,12 @@ import { list_access_tokens } from '@/api/access-tokens/api'
import { TableSkeleton } from '@/components/table-skeleton'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useTranslation } from 'react-i18next'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
export default function AccessTokens() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
// Dialog states
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
const [open, setOpen] = useDialogState<AccessTokensDialogType>(null)
@@ -50,7 +53,7 @@ export default function AccessTokens() {
queryFn: list_access_tokens,
})
const columns = getColumns(t)
const columns = getColumns(t, locale)
return (
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
@@ -58,43 +61,43 @@ export default function AccessTokens() {
<FixedHeader />
<Main>
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
<p className="text-muted-foreground">
{t('accessTokens.description')}
</p>
</div>
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>{t('common.add')}</span> <Plus size={18} />
</Button>
</div>
</div>
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : accessTokens?.length ? (
<AccessTokensTable data={accessTokens} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('accessTokens.noTokensDesc')}
</p>
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
<p className="text-muted-foreground">
{t('accessTokens.description')}
</p>
</div>
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>{t('common.add')}</span> <Plus size={18} />
</Button>
</div>
</div>
</div>
)}
</div>
</Main>
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : accessTokens?.length ? (
<AccessTokensTable data={accessTokens} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('accessTokens.noTokensDesc')}
</p>
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
</div>
</div>
)}
</div>
</Main>
<TokensActionDialog
@@ -42,6 +42,8 @@ import { IconCopy } from '@tabler/icons-react'
import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { useNavigate } from '@tanstack/react-router'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
interface Props {
currentRow: AccountModel
@@ -50,7 +52,8 @@ interface Props {
}
export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const navigate = useNavigate()
const { data: oauth2Tokens, isLoading } = useQuery({
queryKey: ['oauth2-tokens', currentRow.id],
@@ -151,7 +154,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
<TableRow>
<TableCell className='max-w-80'>{t('settings.updatedAt')}</TableCell>
<TableCell>
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true })}
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true, locale })}
</TableCell>
</TableRow>
</TableBody>
@@ -34,6 +34,8 @@ import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle, Clock, Loader2, PlayCircle, FolderSync, FolderCheck } from 'lucide-react'
import { FolderSyncProgress } from './folder-sync-progress'
import { useTranslation } from 'react-i18next'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
interface Props {
open: boolean
@@ -42,7 +44,8 @@ interface Props {
}
export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const { data: state, isLoading } = useQuery({
queryKey: ['running-state', currentRow.id],
queryFn: () => account_state(currentRow.id),
@@ -124,7 +127,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="font-medium">
{state.initial_sync_start_time ? (
<span className="text-green-600">
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true })}
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true, locale })}
</span>
) : (
<span className="flex items-center gap-1 text-yellow-600">
@@ -143,7 +146,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="font-medium">
{state.initial_sync_end_time ? (
<span className="text-green-600">
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true })}
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true, locale })}
</span>
) : state.initial_sync_start_time ? (
<span className="flex items-center gap-1 text-blue-600">
@@ -192,7 +195,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="text-muted-foreground">{t('accounts.runningState.startTime')}</span>
<span className="font-medium">
{state.last_incremental_sync_start ? (
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true })
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true, locale })
) : (
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
)}
@@ -202,7 +205,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="text-muted-foreground">{t('accounts.runningState.endTime')}</span>
<span className="font-medium">
{state.last_incremental_sync_end ? (
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true })
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true, locale })
) : state.last_incremental_sync_start ? (
<span className="text-blue-600">{t('accounts.runningState.inProgress')}</span>
) : (
@@ -243,7 +246,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
>
<div className="flex w-full flex-col gap-1">
<div className="text-xs font-medium text-muted-foreground">
{formatDistanceToNow(new Date(item.at), { addSuffix: true })}
{formatDistanceToNow(new Date(item.at), { addSuffix: true, locale })}
</div>
<div className="font-medium break-words">{item.error}</div>
</div>
@@ -22,6 +22,7 @@ import useMinimalAccountList from "@/hooks/use-minimal-account-list";
import { VirtualizedSelect } from "@/components/virtualized-select";
import { Button } from "@/components/ui/button";
import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
interface AccountSwitcherProps {
@@ -35,7 +36,7 @@ export function AccountSwitcher({
}: AccountSwitcherProps) {
const { accountsOptions, isLoading } = useMinimalAccountList();
const navigate = useNavigate()
const { t } = useTranslation();
if (isLoading) {
return <div>Loading...</div>;
}
@@ -47,7 +48,7 @@ export function AccountSwitcher({
options={accountsOptions}
defaultValue={`${defaultAccountId}`}
onSelectOption={(values) => onAccountSelect(parseInt(values[0], 10))}
placeholder="Select an account"
placeholder={t('oauth2.selectAnAccount')}
noItemsComponent={<div className='space-y-2'>
<p>No active email account.</p>
<Button variant={'outline'} className="py-1 px-3 text-xs" onClick={() => navigate({ to: '/accounts' })}>Add Email Account</Button>
@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { cn, formatBytes } from "@/lib/utils"
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
import { MailIcon, Paperclip, Trash2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
@@ -27,6 +27,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { MailBulkActions } from "./bulk-actions"
import { Badge } from "@/components/ui/badge"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
interface MailListProps {
items: EmailEnvelope[]
@@ -37,8 +38,9 @@ export function MailList({
items,
isLoading,
}: MailListProps) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { currentEnvelope, setCurrentEnvelope, setDeleteIds, setOpen, selected, setSelected } = useMailboxContext()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const handleDelete = (envelope: EmailEnvelope) => {
setDeleteIds(new Set([envelope.id]))
@@ -177,7 +179,7 @@ export function MailList({
<span className={cn(
isSelected ? "text-foreground font-medium" : "text-muted-foreground"
)}>
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true })}
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
</span>
<button
+6 -3
View File
@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { cn, formatBytes } from "@/lib/utils"
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
@@ -29,6 +29,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
interface MailListProps {
items: EmailEnvelope[]
@@ -41,7 +42,9 @@ export function MailList({
isLoading,
onEnvelopeChanged
}: MailListProps) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
const handleToggleAll = () => {
@@ -212,7 +215,7 @@ export function MailList({
<span className="hidden md:inline">{formatBytes(item.size)}</span>
<span className={cn(isSelectedRow ? "text-foreground font-medium" : "text-muted-foreground")}>
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true })}
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
</span>
<DropdownMenu>
+49 -4
View File
@@ -16,7 +16,7 @@
// 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 { enUS, zhCN, zhTW, arSA, de, es, fi, fr, it, ja, ko, nl, ptBR, ru, da, sv, nb, Locale } from 'date-fns/locale';
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
@@ -70,8 +70,12 @@ export function mapToRecordOfArrays(
);
}
export function formatNumber(num: number) {
return new Intl.NumberFormat('en-US').format(num);
export function formatNumber(num: number): string {
const userLocale = navigator.language;
return new Intl.NumberFormat(userLocale, {
maximumFractionDigits: 2,
}).format(num);
}
@@ -126,4 +130,45 @@ export function formatTimestamp(milliseconds: number): string {
const offsetHours = String(Math.floor(Math.abs(timezoneOffset) / 60)).padStart(2, '0');
const offsetMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
}
}
// i18n.language -> date-fns locale
export const dateFnsLocaleMap: Record<string, Locale> = {
en: enUS,
'en-us': enUS,
zh: zhCN,
'zh-cn': zhCN,
'zh-tw': zhTW,
'zh_hk': zhTW,
ar: arSA,
'ar-sa': arSA,
de: de,
'de-de': de,
es: es,
'es-es': es,
fi: fi,
'fi-fi': fi,
fr: fr,
'fr-fr': fr,
it: it,
'it-it': it,
jp: ja,
ja: ja,
'ja-jp': ja,
ko: ko,
'ko-kr': ko,
nl: nl,
'nl-nl': nl,
pt: ptBR,
'pt-br': ptBR,
ru: ru,
'ru-ru': ru,
da: da,
'da-dk': da,
sv: sv,
'sv-se': sv,
no: nb,
'no-no': nb,
};