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
@@ -24,6 +24,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
@@ -32,6 +33,7 @@ interface Props {
}
export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
const { t } = useTranslation()
return (
<Dialog
open={open}
@@ -46,7 +48,7 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
<ScrollArea className="h-[35rem] w-full pr-4 -mr-4 py-1">
<Tabs defaultValue="account" className="w-full">
<TabsList className="grid w-full grid-cols-1">
<TabsTrigger value="account">Account Details</TabsTrigger>
<TabsTrigger value="account">{t('accounts.accountDetails')}</TabsTrigger>
</TabsList>
<TabsContent value="account">
@@ -56,44 +58,44 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
<CardContent className="mt-4">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">ID:</span>
<span className="text-muted-foreground">{t('accounts.id')}:</span>
<span>{currentRow.id}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Email:</span>
<span className="text-muted-foreground">{t('accounts.email')}:</span>
<span>{currentRow.email}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Name:</span>
<span>{currentRow.name ?? "n/a"}</span>
<span className="text-muted-foreground">{t('accounts.name')}:</span>
<span>{currentRow.name ?? t('accounts.notAvailable')}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Enabled:</span>
<span className="text-muted-foreground">{t('accounts.enabled')}:</span>
<Checkbox checked={currentRow.enabled} disabled />
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Incremental Sync Interval:</span>
<span>every {currentRow.sync_interval_min} minutes</span>
<span className="text-muted-foreground">{t('accounts.incrementalSyncInterval')}:</span>
<span>{t('accounts.everyMinutes', { minutes: currentRow.sync_interval_min })}</span>
</div>
<div className="flex flex-col gap-2">
<span className="text-muted-foreground">Capabilities:</span>
<span className="text-muted-foreground">{t('accounts.capabilities')}:</span>
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
{currentRow.capabilities ? currentRow.capabilities.join(", ") : "n/a"}
{currentRow.capabilities ? currentRow.capabilities.join(", ") : t('accounts.notAvailable')}
</code>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Date Selection:</span>
<span className="text-muted-foreground">{t('accounts.dateSelection')}:</span>
<span>
{currentRow.date_since?.fixed
? currentRow.date_since.fixed
? t('accounts.since') + ' ' + currentRow.date_since.fixed
: currentRow.date_since?.relative
? `recent ${currentRow.date_since.relative.value} ${currentRow.date_since.relative.unit}`
: "n/a"}
? t('accounts.recent') + ' ' + currentRow.date_since.relative.value + ' ' + currentRow.date_since.relative.unit
: t('accounts.notAvailable')}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Folder Limit:</span>
<span>{currentRow.folder_limit ? currentRow.folder_limit : "n/a"}</span>
<span className="text-muted-foreground">{t('accounts.folderLimit')}:</span>
<span>{currentRow.folder_limit ? currentRow.folder_limit : t('accounts.notAvailable')}</span>
</div>
</div>
</CardContent>
@@ -102,24 +104,24 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
{/* Server Configuration Card */}
<Card>
<CardHeader>
<CardTitle>Server Configuration (IMAP)</CardTitle>
<CardTitle>{t('accounts.serverConfiguration')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Host:</span>
<span className="text-muted-foreground">{t('accounts.host')}:</span>
<span>{currentRow.imap?.host}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Port:</span>
<span className="text-muted-foreground">{t('accounts.port')}:</span>
<span>{currentRow.imap?.port}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Encryption:</span>
<span className="text-muted-foreground">{t('accounts.encryption')}:</span>
<span>{currentRow.imap?.encryption}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Auth:</span>
<span className="text-muted-foreground">{t('accounts.auth')}:</span>
{currentRow.imap?.auth.auth_type === "OAuth2" ? (
<Badge variant="outline" className="bg-blue-100 text-blue-800">
OAuth2
@@ -131,7 +133,7 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Use Proxy:</span>
<span className="text-muted-foreground">{t('accounts.useProxyField')}:</span>
<span>{currentRow.imap?.use_proxy ? "true" : "false"}</span>
</div>
</div>
@@ -141,13 +143,13 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
{/* Sync Folders Card */}
<Card>
<CardHeader>
<CardTitle>Sync Folders</CardTitle>
<CardTitle>{t('accounts.syncFoldersTitle')}</CardTitle>
</CardHeader>
<CardContent>
{currentRow.sync_folders?.length ? (
<div className="space-y-2">
<div className="text-sm mt-2 text-muted-foreground">
{currentRow.sync_folders.length} folder(s) configured for sync
{t('accounts.foldersConfiguredForSync', { count: currentRow.sync_folders.length })}
</div>
<ScrollArea className="h-[300px] rounded-md border">
<div className="p-2">
@@ -31,11 +31,11 @@ import Step1 from './step1';
import Step2 from './step2';
import Step3 from './step3';
import Step4 from './step4';
import CompleteStep from './complete-step';
import { create_account, autoconfig, update_account } from '@/api/account/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ToastAction } from '@/components/ui/toast';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
const encryptionSchema = z.union([
z.literal('Ssl'),
@@ -48,48 +48,43 @@ const authTypeSchema = z.union([
z.literal('OAuth2'),
]);
const authConfigSchema = (isEdit: boolean) =>
const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
z.object({
auth_type: authTypeSchema,
password: z.string().optional(), // Always optional at base level
password: z.string().optional(),
}).refine(
(data) => {
// Only validate password when:
// 1. Auth type is Password
// 2. In create mode (not edit)
if (data.auth_type === 'Password' && !isEdit) {
return !!data.password?.trim();
}
return true;
},
{
message: 'Password is required when auth method is Password',
message: t('validation.passwordRequired'),
path: ['password'],
}
);
const imapConfigSchema = (isEdit: boolean) =>
const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
z.object({
host: z.string({ required_error: 'IMAP host is required' }).min(1, { message: 'IMAP host cannot be empty' }),
port: z.number().int().min(0, { message: 'IMAP port must be a positive integer' }).max(65535, { message: 'IMAP port must be less than 65536' }),
host: z.string({ required_error: t('validation.imapHostRequired') }).min(1, { message: t('validation.imapHostCannotBeEmpty') }),
port: z.number().int().min(0, { message: t('validation.imapPortMustBePositive') }).max(65535, { message: t('validation.imapPortMustBeLessThan65536') }),
encryption: encryptionSchema,
auth: authConfigSchema(isEdit),
auth: getAuthConfigSchema(isEdit, t),
use_proxy: z.number().optional(),
});
const relativeDateSchema = z.object({
unit: z.enum(["Days", "Months", "Years"], { message: "Please select a unit" }),
value: z.number({ message: 'Please enter a value' }).int().min(1, "Must be at least 1"),
const getRelativeDateSchema = (t: (key: string) => string) => z.object({
unit: z.enum(["Days", "Months", "Years"], { message: t('accounts.selectUnit') }),
value: z.number({ message: t('accounts.enterValue') }).int().min(1, t('accounts.mustBeAtLeast1')),
});
const dateSelectionSchema = z.union([
z.object({ fixed: z.string({ message: "Please select a date" }) },),
z.object({ relative: relativeDateSchema }),
const getDateSelectionSchema = (t: (key: string) => string) => z.union([
z.object({ fixed: z.string({ message: t('accounts.selectDate') }) },),
z.object({ relative: getRelativeDateSchema(t) }),
z.undefined(),
]);
// Define static Account type to avoid z.infer issue with dynamic schema
export type Account = {
name?: string;
email: string;
@@ -115,19 +110,19 @@ export type Account = {
sync_interval_min: number;
};
const accountSchema = (isEdit: boolean) =>
const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
z.object({
name: z.string().optional(),
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
imap: imapConfigSchema(isEdit),
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
imap: getImapConfigSchema(isEdit, t),
enabled: z.boolean(),
date_since: dateSelectionSchema.optional(),
date_since: getDateSelectionSchema(t).optional(),
folder_limit: z
.number({ invalid_type_error: 'Folder limit must be a number' })
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
.int()
.min(100, { message: 'Folder limit must be at least 100' })
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
.optional(),
sync_interval_min: z.number({ invalid_type_error: 'Incremental sync interval must be a number' }).int().min(10, { message: 'Incremental sync interval must be at least 10 minutes' }),
sync_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
});
type Step = {
@@ -137,28 +132,17 @@ type Step = {
};
export type Steps = [
{ id: "complete"; name: "Complete"; fields: [] },
...Step[]
];
const steps: Steps = [
{ id: "complete", name: "Complete", fields: [] },
{
id: "step-1",
name: "Email Address",
fields: ["email"],
},
{
id: "step-2",
name: "IMAP",
fields: ["imap"],
},
{ id: "step-3", name: "Sync Preferences", fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
{ id: "step-4", name: "Summary", fields: [] },
const getSteps = (t: (key: string) => string): Steps => [
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email"] },
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap"] },
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
];
const LAST_STEP = steps.length - 1;
const COMPLETE_STEP = 0;
const LAST_STEP = 4;
interface Props {
currentRow?: AccountModel;
@@ -195,14 +179,13 @@ const emptyImap: ImapConfig = {
const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
const imap = { ...(currentRow.imap ?? emptyImap) };
// Handle password and use_proxy conversion
imap.auth = { ...imap.auth, password: undefined };
if (imap.use_proxy === null) {
imap.use_proxy = undefined;
}
let account = {
name: currentRow.name === null ? undefined : currentRow.name,
return {
name: currentRow.name ?? undefined,
email: currentRow.email,
imap,
enabled: currentRow.enabled,
@@ -210,20 +193,21 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
folder_limit: currentRow.folder_limit ?? undefined,
sync_interval_min: currentRow.sync_interval_min ?? 10,
};
return account;
};
export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation();
const steps = getSteps(t);
const isEdit = !!currentRow;
const [currentStep, setCurrentStep] = React.useState(1);
const { toast } = useToast();
const [autoConfigLoading, setAutoConfigLoading] = React.useState(false);
const accountSchema = getAccountSchema(isEdit, t);
const form = useForm<Account>({
mode: "all",
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
resolver: zodResolver(accountSchema(isEdit)),
resolver: zodResolver(accountSchema),
});
const queryClient = useQueryClient();
@@ -242,9 +226,9 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
function handleSuccess() {
toast({
title: `Account ${isEdit ? 'Updated' : 'Created'}`,
description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`,
action: <ToastAction altText="Close">Close</ToastAction>,
title: isEdit ? t('accounts.accountUpdated') : t('accounts.accountCreated'),
description: isEdit ? t('accounts.accountUpdatedDesc') : t('accounts.accountCreatedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
@@ -256,13 +240,13 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
(isEdit ? t('accounts.updateFailed') : t('accounts.creationFailed'));
toast({
variant: "destructive",
title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`,
title: isEdit ? t('accounts.accountUpdateFailed') : t('accounts.accountCreationFailed'),
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
@@ -289,11 +273,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
if (isEdit) {
updateMutation.mutate(commonData);
} else {
const payload = {
...commonData,
account_type: "IMAP",
};
createMutation.mutate(payload);
createMutation.mutate({ ...commonData, account_type: "IMAP" });
}
},
[isEdit, updateMutation, createMutation]
@@ -302,30 +282,21 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
const handleNav = async (index: number) => {
let isValid = true;
let failedStep = currentStep;
for (let i = currentStep; i < index && isValid; i++) {
for (let i = currentStep - 1; i < index - 1 && isValid; i++) {
isValid = await form.trigger(steps[i].fields);
if (!isValid) {
failedStep = i;
}
}
if (isValid) {
setCurrentStep(index);
} else {
setCurrentStep(failedStep);
if (!isValid) failedStep = i;
}
if (isValid) setCurrentStep(index);
else setCurrentStep(failedStep);
};
async function handleContinue() {
const isValid = await form.trigger(steps[currentStep].fields);
if (!isValid) {
return;
}
const isValid = await form.trigger(steps[currentStep - 1].fields);
if (!isValid) return;
if (currentStep === 1) {
let allValues = form.getValues();
if (
allValues.imap.host.trim() !== "" &&
allValues.imap.port > 0
) {
if (allValues.imap.host.trim() !== "" && allValues.imap.port > 0) {
handleNav(currentStep + 1);
return;
}
@@ -338,15 +309,12 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
form.setValue('imap.host', result.imap.host);
form.setValue('imap.port', result.imap.port);
form.setValue('imap.encryption', result.imap.encryption);
if (result.oauth2) {
form.setValue('imap.auth.auth_type', 'OAuth2');
}
if (result.oauth2) form.setValue('imap.auth.auth_type', 'OAuth2');
}
setAutoConfigLoading(false);
} catch (error) {
console.error('Auto-configuration failed:', error);
setAutoConfigLoading(false);
}
setAutoConfigLoading(false);
handleNav(currentStep + 1);
} else {
handleNav(currentStep + 1);
@@ -364,75 +332,58 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
>
<DialogContent className='max-w-5xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
Click save when you're done.
{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[38rem] w-full pr-4 -mr-4 py-1">
<>
{/* Mobile Steps (hidden on desktop) */}
{currentStep !== COMPLETE_STEP && (
<div className="flex my-5 space-x-4 md:hidden">
{steps.map(
(step, index) =>
index !== COMPLETE_STEP && (
<div className="z-20 my-3 ml-2 flex items-center" key={step.id}>
<Button
className={`size-9 rounded-full border font-bold ${`step-${currentStep}` === step.id ? "" : "bg-gray-200 text-black"
}`}
disabled={`step-${currentStep}` === step.id || currentStep === COMPLETE_STEP}
onClick={() => handleNav(index)}
>
{index}
</Button>
</div>
)
)}
</div>
)}
<div className="flex my-5 space-x-4 md:hidden">
{steps.map((step, index) => (
<Button
key={step.id}
className={`size-9 rounded-full border font-bold ${currentStep === index + 1 ? "bg-primary text-white" : "bg-gray-200 text-black"
}`}
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
))}
</div>
<div className="w-full max-w-full p-4">
<div className="flex md:h-min rounded-xl md:rounded-2xl p-4">
{currentStep !== COMPLETE_STEP && (
<div className="hidden md:block w-[260px] flex-shrink-0 rounded-xl p-5 pt-7 fixed">
{steps.map(
(step, index) =>
index !== COMPLETE_STEP && (
<div className="my-3 ml-2 flex items-center" key={step.id}>
<Button
className={`size-8 border rounded-full text-sm font-bold ${`step-${currentStep}` === step.id
? "bg-primary text-white"
: "bg-gray-200 text-black"
}`}
disabled={`step-${currentStep}` === step.id || currentStep === COMPLETE_STEP}
onClick={() => handleNav(index)}
>
{index}
</Button>
<div className="flex flex-col items-baseline uppercase ml-5">
<span className="text-xs">Step {index}</span>
<span className="font-bold text-sm tracking-wider">{step.name}</span>
</div>
</div>
)
)}
</div>
)}
<div className="hidden md:block w-[260px] flex-shrink-0 rounded-xl p-5 pt-7 fixed">
{steps.map((step, index) => (
<div className="my-3 ml-2 flex items-center" key={step.id}>
<Button
className={`size-8 border rounded-full text-sm font-bold ${currentStep === index + 1 ? "bg-primary text-white" : "bg-gray-200 text-black"
}`}
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
<div className="flex flex-col items-baseline uppercase ml-5">
<span className="text-xs">{t('accounts.step', { index: index + 1 })}</span>
<span className="font-bold text-sm tracking-wider">{step.name}</span>
</div>
</div>
))}
</div>
<Form {...form}>
<form
id="account-register-form"
className={`flex-grow flex flex-col px-4 md:px-8 lg:px-12 ${currentStep !== COMPLETE_STEP ? 'ml-[240px]' : ''
}`}
className="flex-grow flex flex-col px-4 md:px-8 lg:px-12 ml-[240px]"
onSubmit={form.handleSubmit(onSubmit)}
>
{currentStep === 1 && <Step1 isEdit={isEdit} />}
{currentStep === 2 && <Step2 isEdit={isEdit} />}
{currentStep === 3 && <Step3 />}
{currentStep === 4 && <Step4 />}
{currentStep === COMPLETE_STEP && <CompleteStep />}
</form>
</Form>
</div>
@@ -440,60 +391,35 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
</>
</ScrollArea>
<DialogFooter className="flex flex-wrap gap-2">
<Button
disabled={currentStep === 1 || currentStep === COMPLETE_STEP}
type="button"
className="flex-grow sm:flex-grow-0 shadow-none text-nowrap text-sm disabled:invisible"
onClick={() => {
handleNav(currentStep - 1);
}}
>
Go Back
</Button>
<Button
disabled={currentStep === LAST_STEP || currentStep === COMPLETE_STEP}
type="button"
className="flex-grow sm:flex-grow-0 rounded-md md:rounded-lg px-6 disabled:hidden text-sm"
onClick={handleContinue}
>
{autoConfigLoading ? (
<>
<svg
className="animate-spin h-5 w-5 mr-3 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>Auto-configuring...</span>
</>
) : (
"Continue"
)}
</Button>
<Button
disabled={currentStep !== LAST_STEP}
type="submit"
form="account-register-form"
className="flex-grow sm:flex-grow-0 rounded-md text-sm px-7 disabled:hidden md:rounded-lg"
>
{isEdit ? "Save changes" : "Submit"}
</Button>
{currentStep > 1 && (
<Button
type="button"
className="flex-grow sm:flex-grow-0 shadow-none text-nowrap text-sm"
onClick={() => setCurrentStep(currentStep - 1)}
>
{t('accounts.goBack')}
</Button>
)}
{currentStep < LAST_STEP && (
<Button
type="button"
className="flex-grow sm:flex-grow-0 rounded-md md:rounded-lg px-6 text-sm"
onClick={handleContinue}
>
{autoConfigLoading ? t('accounts.autoConfiguring') : t('accounts.continue')}
</Button>
)}
{currentStep === LAST_STEP && (
<Button
type="submit"
form="account-register-form"
className="flex-grow sm:flex-grow-0 rounded-md text-sm px-7 md:rounded-lg"
>
{isEdit ? t('accounts.saveChanges') : t('accounts.submit')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
}
@@ -27,12 +27,16 @@ import { format } from 'date-fns'
import { OAuth2Action } from './oauth2-action'
import { RunningStateCellAction } from './running-state-action'
import { EnableAction } from './enable-action'
import { useTranslation } from 'react-i18next'
export const columns: ColumnDef<AccountModel>[] = [
export function useColumns(): ColumnDef<AccountModel>[] {
const { t } = useTranslation()
return [
{
accessorKey: "id",
header: ({ column }) => (
<DataTableColumnHeader column={column} title='ID' />
<DataTableColumnHeader column={column} title={t('accounts.id')} />
),
cell: ({ row }) => {
return <LongText>{row.original.id}</LongText>
@@ -43,7 +47,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Email' />
<DataTableColumnHeader column={column} title={t('accounts.email')} />
),
cell: ({ row }) => {
return <LongText>{row.original.email}</LongText>
@@ -53,7 +57,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: "enabled",
header: ({ column }) => (
<DataTableColumnHeader className="ml-4" column={column} title='Enabled' />
<DataTableColumnHeader className="ml-4" column={column} title={t('accounts.enabled')} />
),
cell: EnableAction,
meta: { className: 'w-8 text-center' },
@@ -62,7 +66,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
id: 'auth_type',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Auth' />
<DataTableColumnHeader column={column} title={t('accounts.auth')} />
),
cell: OAuth2Action,
meta: { className: 'w-8' },
@@ -72,7 +76,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
id: 'account_type',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Type' />
<DataTableColumnHeader column={column} title={t('accounts.type')} />
),
cell: ({ row }) => {
return <LongText>{row.original.account_type}</LongText>
@@ -84,7 +88,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: "sync_interval_sec",
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Inc Sync' />
<DataTableColumnHeader column={column} title={t('accounts.incSync')} />
),
cell: ({ row }) => {
let account_type = row.original.account_type;
@@ -99,7 +103,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
id: 'running_state',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='State' />
<DataTableColumnHeader column={column} title={t('accounts.state')} />
),
cell: RunningStateCellAction,
meta: { className: 'w-36' },
@@ -108,7 +112,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Created At' />
<DataTableColumnHeader column={column} title={t('accounts.createdAt')} />
),
cell: ({ row }) => {
const created_at = row.original.created_at;
@@ -121,7 +125,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: 'updated_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Updated At' />
<DataTableColumnHeader column={column} title={t('accounts.updatedAt')} />
),
cell: ({ row }) => {
const updated_at = row.original.updated_at;
@@ -135,4 +139,5 @@ export const columns: ColumnDef<AccountModel>[] = [
id: 'actions',
cell: DataTableRowActions,
},
]
]
}
@@ -1,60 +0,0 @@
//
// 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 { CheckCircle } from "lucide-react"; // Using Lucide icon library
import { Button } from "@/components/ui/button"; // Using custom button component
export default function CompleteStep() {
return (
<div className="flex flex-col items-center justify-center h-[25rem] p-6">
{/* Success Icon */}
<div className="mb-6 text-green-500">
<CheckCircle className="w-16 h-16" />
</div>
{/* Success Message */}
<h1 className="text-3xl font-bold mb-4">Registration Successful!</h1>
<p className="text-lg text-gray-600 mb-8 text-center">
Your email account has been successfully added.
</p>
{/* Action Buttons */}
<div className="mt-8 flex gap-4">
<Button
variant="default"
onClick={() => {
// Redirect to home page
window.location.href = "/";
}}
>
Authorize via OAuth2
</Button>
<Button
variant="outline"
onClick={() => {
// View details
window.location.href = "/accounts";
}}
>
View Details
</Button>
</div>
</div>
);
}
@@ -33,6 +33,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
@@ -48,7 +49,7 @@ export function DataTableColumnHeader<TData, TValue>({
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
const { t } = useTranslation()
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
@@ -71,16 +72,16 @@ export function DataTableColumnHeader<TData, TValue>({
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Asc
{t('table.asc')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Desc
{t('table.desc')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Hide
{t('table.hide')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -32,12 +32,14 @@ import {
import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { Mailbox, MessageSquareMore } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface DataTableRowActionsProps {
row: Row<AccountModel>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const account_type = row.original.account_type;
@@ -51,7 +53,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Open menu</span>
<span className='sr-only'>{t('accounts.openMenu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
@@ -66,7 +68,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}
}}
>
Edit
{t('accounts.edit')}
<DropdownMenuShortcut>
<IconEdit size={16} />
</DropdownMenuShortcut>
@@ -77,7 +79,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('sync-folders')
}}
>
Sync Folders
{t('accounts.syncFolders')}
<DropdownMenuShortcut>
<Mailbox size={16} />
</DropdownMenuShortcut>
@@ -88,7 +90,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('detail')
}}
>
Detail
{t('accounts.detail')}
<DropdownMenuShortcut>
<MessageSquareMore size={16} />
</DropdownMenuShortcut>
@@ -101,7 +103,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}}
className='!text-red-500'
>
Delete
{t('accounts.delete')}
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>
@@ -19,6 +19,7 @@
import { Table } from '@tanstack/react-table'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
interface DataTableToolbarProps<TData> {
table: Table<TData>
@@ -27,11 +28,12 @@ interface DataTableToolbarProps<TData> {
export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
return (
<div className='flex items-center justify-between'>
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
<Input
placeholder='Filter account...'
placeholder={t('settings.filterAccount')}
value={(table.getState().globalFilter as string) ?? ''}
onChange={(event) => {
table.setGlobalFilter(event.target.value);
@@ -29,6 +29,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { remove_account } from '@/api/account/api'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
@@ -37,14 +38,15 @@ interface Props {
}
export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
const { t } = useTranslation()
const [value, setValue] = useState('')
const queryClient = useQueryClient();
function handleSuccess() {
toast({
title: 'Account Deleted',
description: 'Your account has been successfully deleted.',
action: <ToastAction altText="Close">Close</ToastAction>,
title: t('dialogs.accountDeleted'),
description: t('dialogs.accountDeletedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
@@ -54,13 +56,13 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
function handleError(error: AxiosError) {
const errorMessage = error.response?.data ||
error.message ||
`Delete failed, please try again later`;
t('dialogs.deleteFailed');
toast({
variant: "destructive",
title: `Account delete Failed`,
title: t('dialogs.accountDeleteFailed'),
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
@@ -89,29 +91,28 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
className='mr-1 inline-block stroke-destructive'
size={18}
/>{' '}
Delete Account Permanently
{t('dialogs.deleteAccountPermanently')}
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
You are deleting <span className='font-bold'>{currentRow.email}</span>.
This will permanently remove:
{t('dialogs.youAreDeleting', { email: currentRow.email })}
</p>
<ul className="list-disc pl-6 space-y-1 text-sm text-muted-foreground">
<li>Account credentials and settings</li>
<li>Local cached metadata and sync status</li>
<li>IMAP synchronization data</li>
<li>OAuth tokens and API credentials</li>
<li>{t('dialogs.accountCredentials')}</li>
<li>{t('dialogs.localCachedMetadata')}</li>
<li>{t('dialogs.imapSyncData')}</li>
<li>{t('dialogs.oauthTokens')}</li>
</ul>
<div className="pt-2">
<Label>
Type the account email to confirm:
{t('dialogs.typeEmailToConfirm')}
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`Type "${currentRow.email}" to confirm`}
placeholder={t('dialogs.typeToConfirm', { email: currentRow.email })}
className="mt-2"
/>
</Label>
@@ -119,15 +120,15 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
<Alert variant='destructive'>
<IconAlertCircle className="h-4 w-4" />
<AlertTitle>This action cannot be undone!</AlertTitle>
<AlertTitle>{t('dialogs.cannotBeUndone')}</AlertTitle>
<AlertDescription>
All related resources will be permanently erased.
{t('dialogs.allResourcesErased')}
</AlertDescription>
</Alert>
</div>
}
confirmText={
deleteMutation.isPending ? 'Deleting...' : 'Permanently Delete Account'
deleteMutation.isPending ? t('dialogs.deleting') : t('dialogs.permanentlyDeleteAccount')
}
isLoading={deleteMutation.isPending}
destructive
@@ -27,12 +27,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { update_account } from '@/api/account/api'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
interface DataTableRowActionsProps {
row: Row<AccountModel>
}
export function EnableAction({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
@@ -42,9 +44,9 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
onSuccess: () => {
setOpen(false);
toast({
title: 'Account Updated',
description: `Account has been successfully ${row.original.enabled ? 'disabled' : 'enabled'}.`,
action: <ToastAction altText="Close">Close</ToastAction>,
title: t('accounts.accountUpdated'),
description: t('accounts.accountHasBeenSuccessfully', { action: row.original.enabled ? t('accounts.disabled').toLowerCase() : t('accounts.enabled').toLowerCase() }),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
})
queryClient.invalidateQueries({ queryKey: ['account-list'] })
},
@@ -56,9 +58,9 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
toast({
variant: "destructive",
title: 'Update Failed',
title: t('accounts.accountUpdateFailed'),
description: errorMessage,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
})
}
})
@@ -77,13 +79,13 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title={`${row.original.enabled ? 'Disable' : 'Enable'} Account`}
title={row.original.enabled ? t('accounts.disableAccount') : t('accounts.enableAccount')}
desc={
`Are you sure you want to ${row.original.enabled ? 'disable' : 'enable'} this account?` +
(row.original.enabled ? ' This will prevent the account from being used.' : '')
t('accounts.areYouSureYouWantTo', { action: row.original.enabled ? t('accounts.disable').toLowerCase() : t('accounts.enable').toLowerCase() }) +
(row.original.enabled ? ' ' + t('accounts.thisWillPreventTheAccountFromBeingUsed') : '')
}
destructive={row.original.enabled}
confirmText={row.original.enabled ? 'Disable' : 'Enable'}
confirmText={row.original.enabled ? t('accounts.disable') : t('accounts.enable')}
isLoading={updateMutation.isPending}
handleConfirm={handleConfirm}
/>
@@ -16,50 +16,53 @@
// 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 { ProgressMap } from "@/api/account/api";
import { Progress } from "@/components/ui/progress";
import { useTranslation } from "react-i18next";
interface Props {
progressMap: ProgressMap | undefined | null;
progressMap: ProgressMap | undefined | null;
}
export function FolderSyncProgress({ progressMap }: Props) {
const { t } = useTranslation();
if (!progressMap || Object.keys(progressMap).length === 0) {
return <div className="text-muted-foreground text-sm">No Data</div>;
}
if (!progressMap || Object.keys(progressMap).length === 0) {
return <div className="text-muted-foreground text-sm">{t('accounts.folderSync.noData')}</div>;
}
const folderNames = Object.keys(progressMap);
const folderNames = Object.keys(progressMap);
if (folderNames.length === 0) {
return <div className="text-muted-foreground text-sm">No folders to sync</div>;
}
if (folderNames.length === 0) {
return <div className="text-muted-foreground text-sm">{t('accounts.folderSync.noFolders')}</div>;
}
return (
<div className="space-y-4">
{folderNames.map((folder) => {
const progress = progressMap[folder];
const percentage =
progress.total_batches > 0
? Math.min((progress.current_batch / progress.total_batches) * 100, 100)
: 0;
const isComplete = progress.current_batch >= progress.total_batches;
const textColor = isComplete ? "text-green-800" : "text-blue-800";
const progressColor = isComplete ? "bg-gray-200 [&>div]:bg-green-800 [&>div]:rounded-full h-1.5" : "bg-gray-200 [&>div]:bg-blue-800 [&>div]:rounded-full h-1.5";
return (
<div className="space-y-4">
{folderNames.map((folder) => {
const progress = progressMap[folder];
const percentage =
progress.total_batches > 0
? Math.min((progress.current_batch / progress.total_batches) * 100, 100)
: 0;
const isComplete = progress.current_batch >= progress.total_batches;
const textColor = isComplete ? "text-green-800" : "text-blue-800";
const progressColor = isComplete
? "bg-gray-200 [&>div]:bg-green-800 [&>div]:rounded-full h-1.5"
: "bg-gray-200 [&>div]:bg-blue-800 [&>div]:rounded-full h-1.5";
return (
<div key={folder} className="space-y-1">
<div className={`flex justify-between text-sm ${textColor}`}>
<span className="truncate max-w-[70%]">{folder}</span>
<span className="text-sm font-medium">
{progress.current_batch}/{progress.total_batches} batches
</span>
</div>
<Progress value={percentage} className={progressColor} />
</div>
);
})}
</div>
);
return (
<div key={folder} className="space-y-1">
<div className={`flex justify-between text-sm ${textColor}`}>
<span className="truncate max-w-[70%]">{folder}</span>
<span className="text-sm font-medium">
{progress.current_batch}/{progress.total_batches} {t('accounts.folderSync.batches')}
</span>
</div>
<Progress value={percentage} className={progressColor} />
</div>
);
})}
</div>
);
}
@@ -34,6 +34,7 @@ import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2 } from 'lucide-react';
import { AccountModel } from '../data/schema';
import { useTranslation } from 'react-i18next';
const accountSchema = () =>
@@ -77,6 +78,7 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): NoSyncAccount => {
export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const isEdit = !!currentRow;
const { toast } = useToast();
@@ -102,9 +104,9 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
function handleSuccess() {
toast({
title: `Account ${isEdit ? 'Updated' : 'Created'}`,
description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`,
action: <ToastAction altText="Close">Close</ToastAction>,
title: isEdit ? t('accounts.accountUpdated') : t('accounts.accountCreated'),
description: isEdit ? t('accounts.accountUpdatedDesc') : t('accounts.accountCreatedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
@@ -120,9 +122,9 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
toast({
variant: "destructive",
title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`,
title: isEdit ? t('accounts.accountUpdateFailed') : t('accounts.accountCreationFailed'),
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
@@ -156,10 +158,10 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
>
<DialogContent className='max-w-2xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
Click save when you're done.
{isEdit ? t('accounts.updateTheEmailAccountHere') : t('accounts.addNewEmailAccountHere')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[23rem] w-full pr-4 -mr-4 py-1'>
@@ -175,14 +177,14 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Email Address:
{t('accounts.emailAddress')}:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe@gmail.com" {...field} />
<Input placeholder={t('accounts.emailPlaceholder')} {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This account is used for identification purposes only and does not require syncing with an email server. It helps with importing email data.
{t('accounts.thisAccountIsUsedForIdentificationPurposesOnly')}
</FormDescription>
</FormItem>
)}
@@ -193,12 +195,12 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Name:
{t('accounts.name')}:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe" {...field} />
<Input placeholder={t('accounts.namePlaceholder')} {...field} />
</FormControl>
<FormDescription>Optional</FormDescription>
<FormDescription>{t('accounts.optional')}</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -208,7 +210,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-col items-start gap-y-1'>
<FormLabel>Enabled:</FormLabel>
<FormLabel>{t('accounts.enabled')}:</FormLabel>
<FormControl>
<Checkbox
checked={field.value}
@@ -216,7 +218,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
/>
</FormControl>
<FormDescription>
Determines whether this account is active. If disabled, the account will not be able to import data or perform queries.
{t('accounts.determinesWhetherThisAccountIsActiveNoSync')}
</FormDescription>
</FormItem>
)}
@@ -234,19 +236,19 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
updateMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
{t('oauth2.saving')}
</>
) : (
"Save changes"
t('accounts.saveChanges')
)
) : (
createMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
{t('oauth2.creating')}
</>
) : (
"Create"
t('common.create')
)
)}
</Button>
@@ -21,11 +21,13 @@ import { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { useTranslation } from 'react-i18next'
interface DataTableRowActionsProps {
row: Row<AccountModel>
}
export function OAuth2Action({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const mailer = row.original
const account_type = mailer.account_type;
@@ -52,5 +54,5 @@ export function OAuth2Action({ row }: DataTableRowActionsProps) {
)
}
return <span className="text-xs text-muted-foreground">Password</span>
return <Button variant={"ghost"} className="text-xs text-muted-foreground">{t('settings.password')}</Button>
}
@@ -33,6 +33,7 @@ import { useQuery } from '@tanstack/react-query'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { TableSkeleton } from '@/components/table-skeleton'
import { useTranslation } from 'react-i18next'
import { FileIcon } from 'lucide-react'
import { format, formatDistanceToNow } from 'date-fns'
import LongText from '@/components/long-text'
@@ -49,6 +50,7 @@ interface Props {
}
export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const { data: oauth2Tokens, isLoading } = useQuery({
queryKey: ['oauth2-tokens', currentRow.id],
@@ -65,21 +67,21 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
await navigator.clipboard.writeText(token);
if (access) {
toast({
title: "Success",
description: "Access token copied to clipboard",
title: t('common.ok'),
description: t('accounts.accessTokenCopiedToClipboard'),
});
} else {
toast({
title: "Success",
description: "Refresh token copied to clipboard",
title: t('common.ok'),
description: t('accounts.refreshTokenCopiedToClipboard'),
});
}
} catch (err) {
toast({
variant: "destructive",
title: "Failed to copy text",
title: t('settings.failedToCopyText'),
description: (err as Error).message,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
}
}, []);
@@ -93,9 +95,9 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader className='text-left'>
<DialogTitle>OAuth2 Tokens</DialogTitle>
<DialogTitle>{t('accounts.oauth2Tokens')}</DialogTitle>
<DialogDescription>
Details of the OAuth2 tokens for the account.
{t('accounts.detailsOfTheOAuth2TokensForTheAccount')}
</DialogDescription>
</DialogHeader>
<Card>
@@ -106,20 +108,20 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
<Table className='w-full'>
<TableHeader>
<TableRow>
<TableHead>Field</TableHead>
<TableHead>Value</TableHead>
<TableHead>Action</TableHead>
<TableHead>{t('accounts.field')}</TableHead>
<TableHead>{t('accounts.value')}</TableHead>
<TableHead>{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className='max-w-80'>OAuth2 Name</TableCell>
<TableCell className='max-w-80'>{t('oauth2.id')}</TableCell>
<TableCell>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.oauth2_name}</LongText>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.oauth2_id}</LongText>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Access Token</TableCell>
<TableCell className='max-w-80'>{t('accessTokens.token')}</TableCell>
<TableCell>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.access_token}</LongText>
</TableCell>
@@ -130,7 +132,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Refresh Token</TableCell>
<TableCell className='max-w-80'>{t('accounts.refreshToken')}</TableCell>
<TableCell>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.refresh_token}</LongText>
</TableCell>
@@ -141,13 +143,13 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Created At</TableCell>
<TableCell className='max-w-80'>{t('settings.createdAt')}</TableCell>
<TableCell>
{format(new Date(oauth2Tokens.created_at), 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Updated At</TableCell>
<TableCell className='max-w-80'>{t('settings.updatedAt')}</TableCell>
<TableCell>
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true })}
</TableCell>
@@ -158,10 +160,11 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
<div className="flex h-[250px] mt-4 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">
<FileIcon className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">No OAuth2 Tokens</h3>
<h3 className="mt-4 text-lg font-semibold">{t('accounts.noOAuth2Tokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
The account has not completed the authorization process. Please
<a onClick={() => navigate({ to: '/oauth2' })} className="ml-1 text-blue-500 underline cursor-pointer">click here</a> to authorize the account.
{t('accounts.theAccountHasNotCompletedTheAuthorizationProcess')}
<a onClick={() => navigate({ to: '/oauth2' })} className="ml-1 text-blue-500 underline cursor-pointer">{t('accounts.clickHere')}</a>
{t('accounts.toAuthorizeTheAccount')}
</p>
</div>
</div>
@@ -170,7 +173,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
</Card>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
@@ -21,14 +21,16 @@ import { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { AccountModel } from '../data/schema';
import { useAccountContext } from '../context';
import { useTranslation } from 'react-i18next';
interface Props {
row: Row<AccountModel>
}
export function RunningStateCellAction({ row }: Props) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
let account_type = row.original.account_type;
if (account_type === "NoSync") {
return <span className="text-xs text-muted-foreground">n/a</span>
@@ -39,7 +41,7 @@ export function RunningStateCellAction({ row }: Props) {
setCurrentRow(row.original)
setOpen('running-state')
}}>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">view details</span>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">{t('accounts.viewDetails')}</span>
</Button>
)
}
@@ -16,7 +16,6 @@
// 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 {
Dialog,
DialogClose,
@@ -34,6 +33,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
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'
interface Props {
open: boolean
@@ -42,6 +42,7 @@ interface Props {
}
export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const { data: state, isLoading } = useQuery({
queryKey: ['running-state', currentRow.id],
queryFn: () => account_state(currentRow.id),
@@ -50,28 +51,27 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
refetchInterval: 5000,
})
// Helper function to calculate duration
const calculateDuration = (start?: number, end?: number) => {
if (!start) {
return (
<span className="text-yellow-600 flex items-center gap-1">
<Clock className="w-4 h-4" /> Not Started
<Clock className="w-4 h-4" /> {t('accounts.runningState.notStarted')}
</span>
);
)
}
if (!end) {
return (
<span className="text-blue-600 flex items-center gap-1">
<PlayCircle className="w-4 h-4" /> In Progress
<PlayCircle className="w-4 h-4" /> {t('accounts.runningState.inProgress')}
</span>
);
)
}
const duration = intervalToDuration({ start: new Date(start), end: new Date(end) })
return (
<span className="text-green-600 flex items-center gap-1">
<CheckCircle className="w-4 h-4" /> {formatDuration(duration, { format: ['hours', 'minutes', 'seconds'] })}
</span>
);
)
}
return (
@@ -105,22 +105,22 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
) : (
<FolderSync className="w-5 h-5 text-blue-500" />
)}
Initial Sync
{t('accounts.runningState.initialSync')}
</h3>
{state.is_initial_sync_completed ? (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
Completed
{t('accounts.runningState.completed')}
</span>
) : (
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
In Progress
{t('accounts.runningState.inProgress')}
</span>
)}
</div>
<div className="grid grid-cols-3 gap-6 w-full">
{/* Start Time */}
<div className="flex flex-col text-sm">
<span className="text-muted-foreground">Start Time:</span>
<span className="text-muted-foreground">{t('accounts.runningState.startTime')}</span>
<span className="font-medium">
{state.initial_sync_start_time ? (
<span className="text-green-600">
@@ -132,13 +132,14 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-yellow-500"></span>
</span>
Not Started
{t('accounts.runningState.notStarted')}
</span>
)}
</span>
</div>
{/* End Time */}
<div className="flex flex-col text-sm">
<span className="text-muted-foreground">End Time:</span>
<span className="text-muted-foreground">{t('accounts.runningState.endTime')}</span>
<span className="font-medium">
{state.initial_sync_end_time ? (
<span className="text-green-600">
@@ -146,33 +147,25 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
</span>
) : state.initial_sync_start_time ? (
<span className="flex items-center gap-1 text-blue-600">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
</span>
In Progress
{t('runningState.inProgress')}
</span>
) : (
<span className="text-yellow-600">Not Started</span>
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
)}
</span>
</div>
{/* Duration */}
<div className="flex flex-col text-sm">
<span className="text-muted-foreground">Duration:</span>
<span className="text-muted-foreground">{t('accounts.runningState.duration')}</span>
<span className="font-medium">
{(() => {
if (!state.initial_sync_start_time)
return <span className="text-yellow-600">Not Started</span>;
if (!state.initial_sync_end_time)
return <span className="text-blue-600 animate-pulse">Calculating...</span>;
const diff = new Date(state.initial_sync_end_time).getTime() - new Date(state.initial_sync_start_time).getTime();
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
return <span className="text-foreground">{h}h {m}m {s}s</span>;
if (!state.initial_sync_start_time) return <span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
if (!state.initial_sync_end_time) return <span className="text-blue-600 animate-pulse">{t('accounts.runningState.calculating')}</span>
const diff = new Date(state.initial_sync_end_time).getTime() - new Date(state.initial_sync_start_time).getTime()
const h = Math.floor(diff / 3600000)
const m = Math.floor((diff % 3600000) / 60000)
const s = Math.floor((diff % 60000) / 1000)
return <span className="text-foreground">{h}h {m}m {s}s</span>
})()}
</span>
</div>
@@ -182,11 +175,9 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<div className="p-4 border rounded-lg bg-card">
{state && (
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3">Sync Progres</h3>
<h3 className="text-base sm:text-lg font-semibold mb-3">{t('accounts.runningState.syncProgress')}</h3>
<ScrollArea className="h-70 sm:h-70 border rounded-md p-2">
<div className='space-y-4'>
<FolderSyncProgress progressMap={state.progress} />
</div>
<FolderSyncProgress progressMap={state.progress} />
</ScrollArea>
</div>
)}
@@ -194,51 +185,52 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
{/* Incremental Sync */}
<div className="p-4 border rounded-lg bg-card">
<h3 className="text-base sm:text-lg font-semibold mb-3">Incremental Sync</h3>
<h3 className="text-base sm:text-lg font-semibold mb-3">{t('accounts.runningState.incrementalSync')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Start Time:</span>
<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 })
) : (
<span className="text-yellow-600">Not Started</span>
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">End Time:</span>
<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 })
) : state.last_incremental_sync_start ? (
<span className="text-blue-600">In Progress</span>
<span className="text-blue-600">{t('accounts.runningState.inProgress')}</span>
) : (
<span className="text-yellow-600">Not Started</span>
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
)}
</span>
</div>
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Duration:</span>
<span className="text-muted-foreground">{t('accounts.runningState.duration')}</span>
<span className="font-medium">
{calculateDuration(state.last_incremental_sync_start, state.last_incremental_sync_end)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sync Interval:</span>
<span className="text-muted-foreground">{t('accounts.runningState.syncInterval')}</span>
<span className="font-medium">
Every {currentRow.sync_interval_min} minutes
{t('accounts.runningState.everyMinutes', { minutes: currentRow.sync_interval_min })}
</span>
</div>
</div>
</div>
</div>
</div>
<div className="p-4 border rounded-lg bg-card">
<h3 className="text-base sm:text-lg font-semibold mb-3">Error Logs</h3>
<h3 className="text-base sm:text-lg font-semibold mb-3">{t('accounts.runningState.errorLogs')}</h3>
<ScrollArea className="h-[20rem] sm:h-[32rem]">
<div className="space-y-3">
{state.errors.length ? (
@@ -259,7 +251,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
))
) : (
<div className="h-full flex justify-center items-center py-8">
<p className="text-sm text-muted-foreground">No error logs available.</p>
<p className="text-sm text-muted-foreground">{t('accounts.runningState.noErrorLogs')}</p>
</div>
)}
</div>
@@ -270,22 +262,18 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
{!isLoading && !state && (
<div className="h-full flex flex-col justify-center items-center py-8 text-center space-y-2">
<p className="text-sm text-muted-foreground">No active state available</p>
<p className="text-xs text-muted-foreground">
Account synchronization may not have started yet
</p>
<p className="text-sm text-muted-foreground">{t('accounts.runningState.noActiveState')}</p>
<p className="text-xs text-muted-foreground">{t('accounts.runningState.accountNotStarted')}</p>
</div>
)}
</ScrollArea>
<DialogFooter className="pt-4 px-4 sm:px-6 pb-4 border-t">
<DialogClose asChild>
<Button variant="outline" className="w-full sm:w-auto">
Close
</Button>
<Button variant="outline" className="w-full sm:w-auto">{t('accounts.runningState.close')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
}
+10 -8
View File
@@ -29,19 +29,21 @@ import {
import { Input } from "@/components/ui/input";
import { Account } from "./action-dialog";
import { useTranslation } from "react-i18next";
interface StepProps {
isEdit: boolean;
}
export default function Step1({ isEdit }: StepProps) {
const { t } = useTranslation()
const { control } = useFormContext<Account>();
return (
<>
<h1 className="my-3 md:mt-8">Email Account Registration</h1>
<h1 className="my-3 md:mt-8">{t('accounts.emailAccountRegistration')}</h1>
<p className="mb-5 md:mb-8">
Please provide your email address. In the next steps, you will configure the IMAP/SMTP details. Using this email address, we will attempt to automatically retrieve the SMTP/IMAP server addresses.
{t('accounts.emailAccountRegistrationDesc')}
</p>
<div className="space-y-8">
<FormField
@@ -50,15 +52,15 @@ export default function Step1({ isEdit }: StepProps) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Email Address:
{t('accounts.emailAddress')}:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe@example.com" readOnly={isEdit} {...field} />
<Input placeholder={t('accounts.emailPlaceholder')} readOnly={isEdit} {...field} />
</FormControl>
<FormMessage />
{isEdit && (
<FormDescription>
The email account address cannot be modified when editing.
{t('accounts.emailCannotBeModified')}
</FormDescription>
)}
</FormItem>
@@ -70,12 +72,12 @@ export default function Step1({ isEdit }: StepProps) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Name:
{t('accounts.name')}:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe" {...field} />
<Input placeholder={t('accounts.namePlaceholder')} {...field} />
</FormControl>
<FormDescription>Optional</FormDescription>
<FormDescription>{t('accounts.optional')}</FormDescription>
<FormMessage />
</FormItem>
)}
+19 -17
View File
@@ -37,12 +37,14 @@ import { useFormContext, useWatch } from "react-hook-form";
import { Account } from "./action-dialog";
import { PasswordInput } from "@/components/password-input";
import useProxyList from "@/hooks/use-proxy";
import { useTranslation } from "react-i18next";
interface StepProps {
isEdit: boolean;
}
export default function Step2({ isEdit }: StepProps) {
const { t } = useTranslation()
const { control } = useFormContext<Account>();
const { proxyOptions } = useProxyList();
const imapAuthMethod = useWatch({
@@ -59,10 +61,10 @@ export default function Step2({ isEdit }: StepProps) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
IMAP Host:
{t('accounts.imapHost')}:
</FormLabel>
<FormControl>
<Input placeholder="e.g imap.example.com" {...field} />
<Input placeholder={t('accounts.imapHostPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -74,10 +76,10 @@ export default function Step2({ isEdit }: StepProps) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
IMAP Port:
{t('accounts.imapPort')}:
</FormLabel>
<FormControl>
<Input type="number" placeholder="e.g 993" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
<Input type="number" placeholder={t('accounts.imapPortPlaceholder')} {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
@@ -88,11 +90,11 @@ export default function Step2({ isEdit }: StepProps) {
name="imap.encryption"
render={({ field }) => (
<FormItem>
<FormLabel>IMAP Auth Method:</FormLabel>
<FormLabel>{t('accounts.imapEncryption')}:</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an authentication method" />
<SelectValue placeholder={t('accounts.selectEncryptionMethod')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -102,7 +104,7 @@ export default function Step2({ isEdit }: StepProps) {
</SelectContent>
</Select>
<FormDescription>
Choose the authentication method for IMAP.
{t('accounts.chooseEncryptionMethod')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -113,11 +115,11 @@ export default function Step2({ isEdit }: StepProps) {
name="imap.auth.auth_type"
render={({ field }) => (
<FormItem>
<FormLabel>IMAP Auth Method:</FormLabel>
<FormLabel>{t('accounts.imapAuthMethod')}:</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an authentication method" />
<SelectValue placeholder={t('accounts.selectAuthMethod')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -126,7 +128,7 @@ export default function Step2({ isEdit }: StepProps) {
</SelectContent>
</Select>
<FormDescription>
Choose the authentication method for IMAP.
{t('accounts.chooseAuthMethod')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -139,15 +141,15 @@ export default function Step2({ isEdit }: StepProps) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
IMAP Password:
{t('accounts.imapPassword')}:
</FormLabel>
<FormControl>
<PasswordInput placeholder={isEdit ? "Leave empty to keep current password" : "Enter your password"} {...field} />
<PasswordInput placeholder={isEdit ? t('accounts.leaveEmptyToKeepPassword') : t('accounts.enterPassword')} {...field} />
</FormControl>
<FormMessage />
{isEdit && (
<FormDescription>
Leave empty to keep the existing password, or enter a new password to update it.
{t('accounts.leaveEmptyToKeepExisting')}
</FormDescription>
)}
</FormItem>
@@ -159,7 +161,7 @@ export default function Step2({ isEdit }: StepProps) {
name='imap.use_proxy'
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">Use Proxy(optional):</FormLabel>
<FormLabel className="flex items-center justify-between">{t('accounts.useProxy')} ({t('accounts.optional')}):</FormLabel>
<FormControl>
<Select
onValueChange={(val) => field.onChange(Number(val))}
@@ -167,7 +169,7 @@ export default function Step2({ isEdit }: StepProps) {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a proxy" />
<SelectValue placeholder={t('accounts.selectProxy')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -178,13 +180,13 @@ export default function Step2({ isEdit }: StepProps) {
</SelectItem>
))
) : (
<SelectItem disabled value="__none__">No proxy available</SelectItem>
<SelectItem disabled value="__none__">{t('settings.noProxies')}</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription className='flex-1'>
Use a SOCKS5 proxy for IMAP connections.
{t('accounts.imapProxy')}
</FormDescription>
<FormMessage />
</FormItem>
+123 -121
View File
@@ -16,7 +16,6 @@
// 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 {
FormField,
FormItem,
@@ -44,85 +43,96 @@ import { cn } from "@/lib/utils";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { useTranslation } from "react-i18next";
export default function Step3() {
const { t } = useTranslation();
const { control, getValues, setValue } = useFormContext<Account>();
const current = getValues();
const [rangeType, setRangeType] = useState<'none' | 'fixed' | 'relative'>(current.date_since ? (current.date_since.fixed ? 'fixed' : 'relative') : 'none')
const [rangeType, setRangeType] = useState<'none' | 'fixed' | 'relative'>(
current.date_since ? (current.date_since.fixed ? 'fixed' : 'relative') : 'none'
);
return (
<>
<div className="space-y-8">
<FormField
control={control}
name="sync_interval_min"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Incremental Sync(minutes):
</FormLabel>
<FormControl>
<Input type="number" placeholder="e.g 300" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-col items-start gap-y-1'>
<FormLabel>Enabled:</FormLabel>
<FormControl>
<Checkbox
className='mt-2'
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Determines whether this account is active. If disabled, related syncs will not run.
</FormDescription>
</FormItem>
)}
/>
<FormLabel className="flex items-center justify-between">
Date Since:
</FormLabel>
<RadioGroup
defaultValue={rangeType}
onValueChange={(value: 'fixed' | 'relative' | 'none') => {
setRangeType(value);
if (value === 'none') {
setValue("date_since", undefined, { shouldValidate: true });
}
<div className="space-y-8">
<FormField
control={control}
name="sync_interval_min"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.incrementalSync')}:
</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accounts.incrementalSyncPlaceholder')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
if (value === 'fixed') {
setValue("date_since", { fixed: undefined }, { shouldValidate: true });
}
<FormField
control={control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-col items-start gap-y-1">
<FormLabel>{t('accounts.enabled')}:</FormLabel>
<FormControl>
<Checkbox
className="mt-2"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('accounts.enabledDescription')}</FormDescription>
</FormItem>
)}
/>
if (value === 'relative') {
setValue("date_since", { relative: { value: undefined, unit: undefined } }, { shouldValidate: true });
}
}}
className='flex flex-row space-x-4'
>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='none' />
<FormLabel className='font-normal'>None</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='fixed' />
<FormLabel className='font-normal'>Fixed</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='relative' />
<FormLabel className='font-normal'>Relative</FormLabel>
</FormItem>
</RadioGroup>
<FormDescription>defines the sync start dateeither specific or relative to now. Preceding emails are excluded,{rangeType === 'fixed' ? " syncs data after a set date" : " shifts the sync date over time, syncing only recent data."}</FormDescription>
{rangeType === 'fixed' && <FormField
<FormLabel className="flex items-center justify-between">{t('accounts.dateSince')}:</FormLabel>
<RadioGroup
defaultValue={rangeType}
onValueChange={(value: 'fixed' | 'relative' | 'none') => {
setRangeType(value);
if (value === 'none') {
setValue("date_since", undefined, { shouldValidate: true });
}
if (value === 'fixed') {
setValue("date_since", { fixed: undefined }, { shouldValidate: true });
}
if (value === 'relative') {
setValue("date_since", { relative: { value: undefined, unit: undefined } }, { shouldValidate: true });
}
}}
className="flex flex-row space-x-4"
>
<FormItem className="flex items-center space-x-3">
<RadioGroupItem value="none" />
<FormLabel className="font-normal">{t('accounts.none')}</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3">
<RadioGroupItem value="fixed" />
<FormLabel className="font-normal">{t('accounts.fixed')}</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3">
<RadioGroupItem value="relative" />
<FormLabel className="font-normal">{t('accounts.relative')}</FormLabel>
</FormItem>
</RadioGroup>
<FormDescription>
{t('accounts.syncStartDateDescription', {
fixedPart: rangeType === 'fixed' ? t('accounts.syncAfterDate') : t('accounts.syncRecentData'),
})}
</FormDescription>
{rangeType === 'fixed' && (
<FormField
control={control}
name="date_since.fixed"
render={({ field }) => (
@@ -131,17 +141,13 @@ export default function Step3() {
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
variant="outline"
className={cn(
"w-[240px] pl-3 text-left font-normal text-sm text-brand-marine-blue",
"w-[240px] pl-3 text-left font-normal text-brand-marine-blue",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
{field.value ? format(field.value, "PPP") : <span>{t('accounts.selectDate')}</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
@@ -152,15 +158,13 @@ export default function Step3() {
selected={field.value ? new Date(new Date(field.value).setHours(0, 0, 0, 0)) : undefined}
onSelect={(value) => {
if (value) {
const formattedDate = value.toLocaleDateString('en-CA')
field.onChange(formattedDate)
const formattedDate = value.toLocaleDateString('en-CA');
field.onChange(formattedDate);
} else {
field.onChange(null)
field.onChange(null);
}
}}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
initialFocus
/>
</PopoverContent>
@@ -168,8 +172,11 @@ export default function Step3() {
<FormMessage />
</FormItem>
)}
/>}
{rangeType === 'relative' && <div className="flex flex-row gap-4">
/>
)}
{rangeType === 'relative' && (
<div className="flex flex-row gap-4">
<div className="flex-1">
<FormField
control={control}
@@ -177,7 +184,7 @@ export default function Step3() {
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="number" placeholder="e.g 1" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
<Input type="number" placeholder="e.g. 1" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
@@ -193,13 +200,13 @@ export default function Step3() {
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select unit" />
<SelectValue placeholder={t('accounts.selectUnit')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Days">Days</SelectItem>
<SelectItem value="Months">Months</SelectItem>
<SelectItem value="Years">Years</SelectItem>
<SelectItem value="Days">{t('accounts.days')}</SelectItem>
<SelectItem value="Months">{t('accounts.months')}</SelectItem>
<SelectItem value="Years">{t('accounts.years')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -207,33 +214,28 @@ export default function Step3() {
)}
/>
</div>
</div>}
<FormField
control={control}
name="folder_limit"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Folder Sync Limit:
</FormLabel>
<FormDescription>
Limit the number of emails to sync per folder (minimum 100). Leave empty for no limit.
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="e.g. 1000"
{...field}
onChange={(e) =>
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
</div>
)}
<FormField
control={control}
name="folder_limit"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">{t('accounts.folderLimit')}:</FormLabel>
<FormDescription>{t('accounts.folderLimitDescription')}</FormDescription>
<FormControl>
<Input
type="number"
placeholder={t('accounts.folderLimitPlaceholder')}
{...field}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
);
}
}
+73 -90
View File
@@ -20,102 +20,85 @@
import { useFormContext } from "react-hook-form";
import { Account } from "./action-dialog";
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
import { useTranslation } from "react-i18next";
export default function Step4() {
const { t } = useTranslation();
const { getValues } = useFormContext<Account>();
const summaryData = getValues();
return (
<>
<div className="p-5 rounded-xl">
<Accordion type="multiple" defaultValue={['email', 'name', 'minimal_sync', 'isolated_index', 'imap', 'smtp', 'date_since', 'folder_limit', 'sync_folders', 'language', 'sync_interval']}>
<AccordionItem key="email" value="email">
<AccordionTrigger className="font-medium capitalize text-gray-600">
Email:
</AccordionTrigger>
<AccordionContent>
{summaryData.email}
</AccordionContent>
</AccordionItem>
<AccordionItem key="name" value="name">
<AccordionTrigger className="font-medium capitalize text-gray-600">
Name:
</AccordionTrigger>
<AccordionContent>
{summaryData.name ?? "n/a"}
</AccordionContent>
</AccordionItem>
<AccordionItem key="imap" value='imap'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Imap:
</AccordionTrigger>
<AccordionContent>
<div className="overflow-x-auto">
<table className="min-w-full divide-y">
<tbody className="divide-y">
<div className="p-5 rounded-xl">
<Accordion type="multiple" defaultValue={['email', 'name', 'imap', 'date_since', 'folder_limit', 'sync_interval']}>
<AccordionItem key="email" value="email">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.email')}:</AccordionTrigger>
<AccordionContent>{summaryData.email}</AccordionContent>
</AccordionItem>
<AccordionItem key="name" value="name">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.name')}:</AccordionTrigger>
<AccordionContent>{summaryData.name ?? t('accounts.notAvailable')}</AccordionContent>
</AccordionItem>
<AccordionItem key="imap" value="imap">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.imap')}:</AccordionTrigger>
<AccordionContent>
<div className="overflow-x-auto">
<table className="min-w-full divide-y">
<tbody className="divide-y">
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.host')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.host}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.port')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.port}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.encryption')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.encryption}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.authType')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.auth.auth_type}</td>
</tr>
{summaryData.imap.auth.auth_type === 'Password' && (
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">host:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.host}</td>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.password')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm break-words">{summaryData.imap.auth.password}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">port:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.port}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">encryption:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.encryption}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">auth_type:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.auth.auth_type}</td>
</tr>
{summaryData.imap.auth.auth_type === 'Password' && (
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">password:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm break-words">
{summaryData.imap.auth.password}
</td>
</tr>
)}
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">use proxy:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.use_proxy ? "true" : "false"}</td>
</tr>
</tbody>
</table>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem key="date_since" value='date_since'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Date Selection:
</AccordionTrigger>
<AccordionContent>
{summaryData.date_since?.fixed ?
'since ' + summaryData.date_since.fixed
: summaryData.date_since?.relative && summaryData.date_since.relative.value && summaryData.date_since.relative.unit ?
'recent ' + summaryData.date_since.relative.value + ' ' + summaryData.date_since.relative.unit
: 'n/a'}
</AccordionContent>
</AccordionItem>
<AccordionItem key="folder_limit" value='folder_limit'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Folder Sync Limit:
</AccordionTrigger>
<AccordionContent>
{summaryData.folder_limit ? summaryData.folder_limit : 'n/a'}
</AccordionContent>
</AccordionItem>
<AccordionItem key="sync_interval" value='sync_interval'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Incremental Sync Interval:
</AccordionTrigger>
<AccordionContent>
{summaryData.sync_interval_min} minutes
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</>
)}
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.useProxyField')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.use_proxy ? t('common.yes') : t('common.no')}</td>
</tr>
</tbody>
</table>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem key="date_since" value="date_since">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.dateSelection')}:</AccordionTrigger>
<AccordionContent>
{summaryData.date_since?.fixed
? t('accounts.since') + ' ' + summaryData.date_since.fixed
: summaryData.date_since?.relative && summaryData.date_since.relative.value && summaryData.date_since.relative.unit
? t('accounts.recent') + ' ' + summaryData.date_since.relative.value + ' ' + summaryData.date_since.relative.unit
: t('accounts.notAvailable')}
</AccordionContent>
</AccordionItem>
<AccordionItem key="folder_limit" value="folder_limit">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.folderLimit')}:</AccordionTrigger>
<AccordionContent>{summaryData.folder_limit ?? t('accounts.notAvailable')}</AccordionContent>
</AccordionItem>
<AccordionItem key="sync_interval" value="sync_interval">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.incrementalSync')}:</AccordionTrigger>
<AccordionContent>{summaryData.sync_interval_min} {t('accounts.minutes')}</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
}
@@ -39,6 +39,7 @@ import { update_account } from '@/api/account/api'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
@@ -50,7 +51,7 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
const [selectedFolders, setSelectedFolders] = useState<string[]>(currentRow.sync_folders || []);
const [isSubmitting, setIsSubmitting] = useState(false);
const queryClient = useQueryClient();
const { t } = useTranslation()
const { data: mailboxes, isLoading } = useQuery({
queryKey: ['account-mailboxes', currentRow.id],
queryFn: () => list_mailboxes(currentRow.id, true),
@@ -84,9 +85,9 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
if (allMailSelected) {
toast({
title: 'Heads Up: "All Mail" Folder Selected',
description: 'Selecting folders with the "All Mail" attribute will likely lead to duplicating messages already synced from folders like Inbox and Sent. This may consume significantly more storage space.',
action: <ToastAction altText="I Understand">OK</ToastAction>,
title: t('accounts.allMailFolderSelected'),
description: t('accounts.allMailFolderSelectedDesc'),
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
});
}
setSelectedFolders(selected);
@@ -105,7 +106,7 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
setSelectedFolders(validFolderNames);
if (validFolderNames.length < mailboxes.length) {
toast({
description: "Selected standard folders. 'All Mail' was skipped to avoid duplicates.",
description: t('accounts.allMailSkipped'),
});
}
}, [mailboxes]);
@@ -123,9 +124,9 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
function handleSuccess() {
toast({
title: 'Account Sync Folders Updated',
description: 'Account has been successfully updated.',
action: <ToastAction altText="Close">Close</ToastAction>,
title: t('accounts.accountSyncFoldersUpdated'),
description: t('accounts.accountUpdatedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
@@ -136,13 +137,13 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
function handleError(error: AxiosError) {
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
'Update failed, please try again later';
t('accounts.updateFailed');
toast({
variant: "destructive",
title: 'Account Sync Folders Update Failed',
title: t('accounts.accountSyncFoldersUpdateFailed'),
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
setIsSubmitting(false);
console.error(error);
@@ -151,8 +152,8 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
const handleSubmit = async () => {
if (selectedFolders.length === 0) {
toast({
title: 'Error',
description: 'Please select at least one folder',
title: t('common.error'),
description: t('accounts.selectAtLeastOneFolder'),
variant: 'destructive',
});
return;
@@ -167,9 +168,9 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Select Sync Folders</DialogTitle>
<DialogTitle>{t('accounts.selectSyncFolders')}</DialogTitle>
<DialogDescription>
Choose folders to sync for {currentRow.email}, Newly added folders will begin downloading during the next sync cycle.
{t('accounts.chooseFoldersToSync', { "email": currentRow.email })}
</DialogDescription>
</DialogHeader>
@@ -184,7 +185,7 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
className="h-8"
>
<CheckSquare className="w-4 h-4 mr-2" />
Select All
{t('common.selectAll')}
</Button>
<Button
variant="outline"
@@ -194,11 +195,11 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
className="h-8"
>
<Square className="w-4 h-4 mr-2" />
Deselect All
{t('common.deselectAll')}
</Button>
</div>
<div className="text-sm text-muted-foreground">
{selectedFolders.length} folder(s) selected
{t('accounts.foldersSelected', { count: selectedFolders.length })}
</div>
</div>
<ScrollArea className="h-[30rem] w-full pr-4 -mr-4 py-1">
@@ -44,6 +44,7 @@ import {
import { AccountModel } from '../data/schema'
import { DataTablePagination } from './data-table-pagination'
import { DataTableToolbar } from './data-table-toolbar'
import { useTranslation } from 'react-i18next'
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -59,6 +60,7 @@ interface DataTableProps {
export function AccountTable({ columns, data }: DataTableProps) {
const { t } = useTranslation()
const [rowSelection, setRowSelection] = useState({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
@@ -140,7 +142,7 @@ export function AccountTable({ columns, data }: DataTableProps) {
colSpan={columns.length}
className='h-24 text-center'
>
No results.
{t('common.table.noResults')}
</TableCell>
</TableRow>
)}
+13 -10
View File
@@ -22,7 +22,7 @@ import useDialogState from '@/hooks/use-dialog-state'
import { Button } from '@/components/ui/button'
import { Main } from '@/components/layout/main'
import { AccountActionDialog } from './components/action-dialog'
import { columns } from './components/columns'
import { useColumns } from './components/columns'
import { AccountDeleteDialog } from './components/delete-dialog'
import { AccountTable } from './components/table'
import AccountProvider, {
@@ -41,8 +41,11 @@ import { FixedHeader } from '@/components/layout/fixed-header'
import { SyncFoldersDialog } from './components/sync-folders'
import { NoSyncAccountDialog } from './components/nosync-dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
export default function Accounts() {
const { t } = useTranslation()
const columns = useColumns()
// Dialog states
const [currentRow, setCurrentRow] = useState<AccountModel | null>(null)
const [open, setOpen] = useDialogState<AccountDialogType>(null)
@@ -63,9 +66,9 @@ export default function Accounts() {
{/* Header Section */}
<div className='mb-2 flex items-center justify-between flex-wrap gap-x-4 gap-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>Email Accounts</h2>
<h2 className='text-2xl font-bold tracking-tight'>{t('accounts.title')}</h2>
<p className='text-muted-foreground'>
Manage and configure your email accounts.
{t('accounts.description')}
</p>
</div>
<div className="flex gap-2">
@@ -75,7 +78,7 @@ export default function Accounts() {
className="rounded-r-none border-r-0"
>
<Plus className="h-4 w-4" />
Add IMAP
{t('accounts.addImap')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -84,13 +87,13 @@ export default function Accounts() {
className="h-9 w-9 rounded-l-none border-l-0"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More account types</span>
<span className="sr-only">{t('accounts.moreAccountTypes')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOpen("add-nosync")}>
<Plus className="h-4 w-4" />
Add NoSync
{t('accounts.addNoSync')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -110,15 +113,15 @@ export default function Accounts() {
<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="RustMailer Logo"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">No Account Configurations</h3>
<h3 className="mt-4 text-lg font-semibold">{t('accounts.noAccountConfigurations')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
You haven't added any Account configurations yet. Add one to start using Account features.
{t('accounts.noAccountConfigurationsDesc')}
</p>
<div className="mt-4 flex flex-col items-center gap-3 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4">
<Button variant="default" className="w-64" onClick={() => setOpen("add-imap")}>
Add Configuration
{t('accounts.addConfiguration')}
</Button>
</div>
</div>