mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(i18n): Implement internationalization for date distance
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user