mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(i18n): add language switcher in top-right corner for multi-language support #9
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 date—either 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user