feat(i18n): add language switcher in top-right corner for multi-language support #9

This commit is contained in:
rustmailer
2025-11-24 21:51:16 +08:00
parent 5ebc7394d0
commit ea81d0298b
119 changed files with 15277 additions and 1662 deletions
+16 -17
View File
@@ -27,6 +27,7 @@ import { useUpdateTags } from '@/hooks/use-update-tags';
import { toast } from '@/hooks/use-toast';
import { EmailEnvelope } from '@/api';
import { validateTag } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface Props {
open: boolean
@@ -40,6 +41,7 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
const [commandOpen, setCommandOpen] = useState(false);
const { t } = useTranslation();
useEffect(() => {
if (open && currentEnvelope) {
@@ -54,7 +56,7 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: 'Invalid tag',
title: t('search.addTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
@@ -84,11 +86,11 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
{
onSuccess: () => {
toast({
title: 'Tags updated',
title: t('search.addTags.updatedTitle'),
description: (
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500" />
<span>Successfully updated tags</span>
<span>{t('search.addTags.updatedDesc')}</span>
</div>
),
});
@@ -96,8 +98,8 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
},
onError: (error: any) => {
toast({
title: 'Failed to update tags',
description: error?.message || 'Please try again',
title: t('search.addTags.updateFailedTitle'),
description: error?.message || t('search.addTags.tryAgain'),
variant: 'destructive',
});
},
@@ -115,14 +117,14 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TagIcon className="h-5 w-5" />
Edit Tags
{t('search.addTags.title')}
</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-4">
<div className="flex flex-wrap gap-2">
{selectedTags.length === 0 ? (
<p className="text-sm text-muted-foreground">No tags yet</p>
<p className="text-sm text-muted-foreground">{t('search.addTags.none')}</p>
) : (
selectedTags.map(tag => (
<Badge key={tag} variant="secondary" className="gap-1 pr-1 h-7">
@@ -141,7 +143,7 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
<div className="space-y-2">
<div className="relative">
<CommandInput
placeholder="Search or create new tag..."
placeholder={t('search.addTags.searchPlaceholder')}
value={inputValue}
onValueChange={setInputValue}
onFocus={() => setCommandOpen(true)}
@@ -167,10 +169,7 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
</div>
{inputValue.trim() && filteredSuggestions.length === 0 && (
<div className="px-1 text-xs text-muted-foreground animate-in fade-in duration-200">
Press <kbd className="px-1.5 py-0.5 rounded bg-muted font-medium">Enter</kbd>
or click
<kbd className="px-1.5 py-0.5 rounded bg-muted font-medium">+</kbd>
to create tag "<span className="font-medium text-foreground">{inputValue}</span>"
{t('search.addTags.createHint', { tag: inputValue })}
</div>
)}
{commandOpen && inputValue && filteredSuggestions.length > 0 && (
@@ -195,20 +194,20 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">
{selectedTags.length} tag{selectedTags.length !== 1 ? 's' : ''} selected
{t('search.addTags.selectedCount', { count: selectedTags.length })}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('search.addTags.cancel')}
</Button>
<Button onClick={handleSave} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
{t('search.addTags.saving')}
</>
) : (
'Save'
t('search.addTags.save')
)}
</Button>
</div>
@@ -216,4 +215,4 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
</DialogContent>
</Dialog>
);
}
}
+27 -13
View File
@@ -30,6 +30,7 @@ import {
TooltipContent,
} from '@/components/ui/tooltip'
import { useSearchContext } from './context'
import { useTranslation } from 'react-i18next'
type MailBulkActionsProps = {
children?: React.ReactNode
@@ -38,12 +39,11 @@ type MailBulkActionsProps = {
export function MailBulkActions({ children }: MailBulkActionsProps) {
const { selected, setSelected, setOpen, setToDelete } = useSearchContext()
const toolbarRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const selectedCount = Array.from(selected.values())
.reduce((sum, set) => sum + set.size, 0)
const handleClearSelection = () => {
setSelected(new Map())
}
@@ -114,7 +114,9 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
<div
ref={toolbarRef}
role="toolbar"
aria-label={`Bulk actions for ${selectedCount} selected email${selectedCount > 1 ? 's' : ''}`}
aria-label={t('search.bulkActions.ariaLabel', {
count: selectedCount,
})}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cn(
@@ -130,6 +132,7 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
'flex items-center gap-x-2'
)}
>
{/* Clear Selection */}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -137,27 +140,32 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
size="icon"
onClick={handleClearSelection}
className="size-6 rounded-full"
aria-label="Clear selection"
aria-label={t('search.bulkActions.clear')}
>
<X className="h-3 w-3" />
<span className="sr-only">Clear selection</span>
<span className="sr-only">{t('search.bulkActions.clear')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Clear selection (Escape)</TooltipContent>
<TooltipContent>
{t('search.bulkActions.clearWithKey', { key: 'Escape' })}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<div className="flex items-center gap-x-1 text-sm" id="bulk-actions-desc">
{/* Selected Count */}
<div className="flex items-center gap-x-1 text-sm">
<Badge variant="default" className="min-w-8 rounded-lg">
{selectedCount}
</Badge>{' '}
<span className="hidden sm:inline">
email{selectedCount > 1 ? 's' : ''}
</span>{' '}
selected
{t('search.bulkActions.selected', { count: selectedCount })}
</span>
</div>
<Separator orientation="vertical" className="h-5" />
{/* Delete */}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -167,14 +175,20 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
className="gap-1"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Delete</span>
<span className="hidden sm:inline">
{t('search.bulkActions.delete')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>Delete selected emails</TooltipContent>
<TooltipContent>
{t('search.bulkActions.deleteDesc')}
</TooltipContent>
</Tooltip>
{children}
</div>
</div>
</>
)
}
}
+38 -34
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { IconAlertTriangle } from '@tabler/icons-react'
import { toast } from '@/hooks/use-toast'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@@ -25,6 +24,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { delete_messages } from '@/api/mailbox/envelope/api'
import { useSearchContext } from './context'
import { mapToRecordOfArrays } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
@@ -32,38 +32,46 @@ interface Props {
}
export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
const queryClient = useQueryClient();
const queryClient = useQueryClient()
const { toDelete, setToDelete, setSelected } = useSearchContext()
const { t } = useTranslation()
const deleteMutation = useMutation({
mutationFn: ({ payload }: { payload: Record<string, number[]> }) => delete_messages(payload),
mutationFn: ({ payload }: { payload: Record<string, number[]> }) =>
delete_messages(payload),
retry: false,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['search-messages'], exact: false });
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
onOpenChange(false);
setToDelete(new Map());
setSelected(new Map());
queryClient.invalidateQueries({ queryKey: ['search-messages'], exact: false })
queryClient.invalidateQueries({ queryKey: ['all-tags'] })
onOpenChange(false)
setToDelete(new Map())
setSelected(new Map())
toast({
title: 'Messages deleted successfully',
description: 'The messages have been deleted.',
});
title: t('search.delete.successTitle'),
description: t('search.delete.successDesc'),
})
},
onError: (error: any) => {
toast({
title: 'Failed to delete messages',
title: t('search.delete.errorTitle'),
description: `${error.message}`,
variant: 'destructive',
});
})
},
});
})
const handleDelete = () => {
const payload = mapToRecordOfArrays(toDelete);
const payload = mapToRecordOfArrays(toDelete)
deleteMutation.mutate({ payload })
}
const isLoading = deleteMutation.isPending
const emailCount = Array.from(toDelete.values()).reduce(
(sum, set) => sum + set.size,
0
)
return (
<ConfirmDialog
open={open}
@@ -71,41 +79,37 @@ export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
handleConfirm={handleDelete}
className="max-w-xl"
isLoading={isLoading}
destructive
title={
<span className='text-destructive'>
<span className="text-destructive">
<IconAlertTriangle
className='mr-1 inline-block stroke-destructive'
className="mr-1 inline-block stroke-destructive"
size={18}
/>{' '}
Delete Email
{t('search.delete.title')}
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
Are you sure you want to delete{' '}
<span className='font-bold'>
{(() => {
const emailCount = Array.from(toDelete.values())
.reduce((sum, set) => sum + set.size, 0);
return emailCount > 1 ? `this ${emailCount} emails` : 'this email';
})()}
</span>{' '}
<div className="space-y-4">
<p className="mb-2">
{t('search.delete.confirmPrefix')}{' '}
<span className="font-bold">
{t('search.delete.countLabel', { count: emailCount })}
</span>
?
<br />
This action will delete the selected email(s) from local database. the email(s) will be permanently deleted, and cannot be recovered.
{t('search.delete.confirmDetail')}
</p>
<Alert variant='destructive'>
<AlertTitle>Warning!</AlertTitle>
<Alert variant="destructive">
<AlertTitle>{t('search.delete.warningTitle')}</AlertTitle>
<AlertDescription>
Please be cautious before proceeding.
{t('search.delete.warningDesc')}
</AlertDescription>
</Alert>
</div>
}
confirmText='Delete'
destructive
confirmText={t('search.delete.confirmButton')}
/>
)
}
+9 -7
View File
@@ -36,8 +36,10 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/co
import { Button } from '@/components/ui/button';
import { EnvelopeTags } from './tag-facet';
import { EditTagsDialog } from './add-tag-dialog';
import { useTranslation } from 'react-i18next';
export default function Search() {
const { t } = useTranslation()
const [selectedEnvelope, setSelectedEnvelope] = React.useState<EmailEnvelope | undefined>(undefined);
const [open, setOpen] = useDialogState<SearchDialogType>(null)
const [toDelete, setToDelete] = React.useState<Map<number, Set<number>>>(new Map());
@@ -89,13 +91,13 @@ export default function Search() {
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="mr-2 h-4 w-4" />
Tag Filter
{t('search.tagFilter')}
{selectedTags.length > 0 && ` (${selectedTags.length})`}
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80">
<SheetHeader>
<SheetTitle>Tag Filter</SheetTitle>
<SheetTitle>{t('search.tagFilter')}</SheetTitle>
</SheetHeader>
<div className="mt-6">
<EnvelopeTags
@@ -119,14 +121,14 @@ export default function Search() {
<div className="flex-1 min-w-0 space-y-4">
<Button size="sm" onClick={() => setOpen("search-form")}>
<SearchIcon className="mr-2 h-4 w-4" />
Search
{t('common.search')}
</Button>
{isLoading && (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent"></div>
<p className="text-sm">Searching, please wait</p>
<p className="text-sm">{t('search.searching')}</p>
</div>
</CardContent>
</Card>
@@ -136,11 +138,11 @@ export default function Search() {
<div className="bg-muted/50 border-2 border-dashed rounded-xl w-24 h-24 mx-auto flex items-center justify-center">
<SearchIcon className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium">No emails found</h3>
<h3 className="text-lg font-medium">{t('search.noEmailsFound')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{Object.keys(filter).length === 0
? "Start by entering a keyword, sender, or using advanced filters."
: "Try adjusting your search criteria or clearing filters."}
? t('search.startSearching')
: t('search.adjustSearch')}
</p>
</div>}
{total > 0 && <ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
@@ -21,6 +21,7 @@ import { useSearchContext } from './context'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { MailMessageView } from './mail-message-view'
import { useTranslation } from 'react-i18next'
interface Props {
@@ -29,6 +30,7 @@ interface Props {
}
export function MailDisplayDrawer({ open, onOpenChange }: Props) {
const { t } = useTranslation()
const { currentEnvelope } = useSearchContext()
@@ -41,7 +43,7 @@ export function MailDisplayDrawer({ open, onOpenChange }: Props) {
<DialogHeader className="p-4 pb-3 border-b shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
Email Viewer
{t('mail.emailViewer')}
</DialogTitle>
</div>
</DialogHeader>
@@ -50,7 +52,7 @@ export function MailDisplayDrawer({ open, onOpenChange }: Props) {
{currentEnvelope ? (
<MailMessageView envelope={currentEnvelope} />
) : (
<div className="p-8 text-center text-muted-foreground">No message selected</div>
<div className="p-8 text-center text-muted-foreground">{t('mail.noMessageSelected')}</div>
)}
</div>
</ScrollArea>
+7 -9
View File
@@ -25,9 +25,10 @@ import { Checkbox } from "@/components/ui/checkbox" // shadcn Checkbox
import { EmailEnvelope } from "@/api"
import { useSearchContext } from "./context"
import { MailBulkActions } from "./bulk-actions"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useTranslation } from 'react-i18next'
interface MailListProps {
items: EmailEnvelope[]
@@ -40,6 +41,7 @@ export function MailList({
isLoading,
onEnvelopeChanged
}: MailListProps) {
const { t } = useTranslation()
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
const handleToggleAll = () => {
@@ -141,8 +143,8 @@ export function MailList({
/>
<span className="text-xs text-muted-foreground">
{totalSelected > 0
? `${totalSelected} selected`
: "Select all"}
? `${totalSelected} ${t('common.selected')}`
: t('common.selectAll')}
</span>
</div>
)}
@@ -227,14 +229,10 @@ export function MailList({
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
<span className="sr-only">More actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
@@ -244,7 +242,7 @@ export function MailList({
}}
>
<TagIcon className="ml-2 h-4 w-4" />
Edit Tags
{t('search.editTag')}
</DropdownMenuItem>
<DropdownMenuItem
@@ -256,7 +254,7 @@ export function MailList({
}}
>
<Trash2 className="ml-2 h-4 w-4" />
Delete
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+23 -20
View File
@@ -38,6 +38,7 @@ import { AxiosError } from 'axios';
import { useSearchContext } from './context';
import { MailThreadDialog } from './thread-dialog';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import { useTranslation } from 'react-i18next';
interface MailMessageViewProps {
@@ -57,6 +58,7 @@ interface MailMessageViewProps {
}
const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines }) => {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false);
return (
<div className="text-xs">
@@ -73,7 +75,7 @@ const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines
className="text-blue-500 hover:underline text-xs"
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'show less' : 'show more...'}
{expanded ? t('common.showLess') : t('common.showMore')}
</button>
)}
</div>
@@ -88,6 +90,7 @@ export function MailMessageView({
showAttachments = true,
showHeader = true
}: MailMessageViewProps) {
const { t } = useTranslation()
const { setToDelete, setOpen } = useSearchContext();
const [content, setContent] = useState<string | null>(null);
@@ -106,7 +109,7 @@ export function MailMessageView({
onError: (error: any) => {
setDownloadingAttachmentFileName(null);
toast({
title: 'Failed to download file',
title: t('mail.failedToDownloadFile'),
description: error.message,
variant: 'destructive',
});
@@ -124,7 +127,7 @@ export function MailMessageView({
onError: (error: any) => {
setLoading(false);
toast({
title: 'Failed to load email message.',
title: t('mail.failedToLoadEmail'),
description: error.message,
variant: 'destructive',
});
@@ -164,18 +167,18 @@ export function MailMessageView({
const downloadEmlFile = async () => {
try {
toast({ title: 'Download started', description: `"${envelope.id}" is being downloaded` });
toast({ title: t('mail.downloadStarted'), description: t('mail.isBeingDownloaded', { id: envelope.id }) });
await download_message(envelope.account_id, envelope.id);
toast({ title: 'Download complete', description: `"${envelope.id}" downloaded` });
toast({ title: t('mail.downloadComplete'), description: t('mail.downloaded', { id: envelope.id }) });
} catch (error) {
let msg = 'Failed to download email';
let msg = t('mail.downloadFailed');
if (error instanceof AxiosError) {
msg = error.response?.data?.message || error.response?.data?.error || error.message;
if (error.response?.status) msg = `${error.response.status}: ${msg}`;
} else if (error instanceof Error) {
msg = error.message;
}
toast({ title: 'Download failed', description: msg, variant: 'destructive' });
toast({ title: t('mail.downloadFailed'), description: msg, variant: 'destructive' });
}
};
@@ -184,31 +187,31 @@ export function MailMessageView({
{/* Header Info */}
{showHeader && <div className="grid gap-1 text-xs">
<div className="flex space-x-2">
<span className="font-medium text-gray-400">Account:</span>
<span className="font-medium text-gray-400">{t('mail.account')}:</span>
<span>{getEmailById(envelope.account_id)}</span>
</div>
<div className="flex space-x-2">
<span className="font-medium text-gray-400">Id:</span>
<span className="font-medium text-gray-400">{t('mail.id')}:</span>
<span>{envelope.id}</span>
</div>
{envelope.from && (
<div className="flex space-x-2">
<span className="font-medium text-gray-400">From:</span>
<span className="font-medium text-gray-400">{t('mail.from')}:</span>
<span>{envelope.from}</span>
</div>
)}
{envelope.to && envelope.to.length > 0 && <Multilines title="To" lines={envelope.to} />}
{envelope.cc && envelope.cc.length > 0 && <Multilines title="Cc" lines={envelope.cc} />}
{envelope.bcc && envelope.bcc.length > 0 && <Multilines title="Bcc" lines={envelope.bcc} />}
{envelope.to && envelope.to.length > 0 && <Multilines title={t('mail.to')} lines={envelope.to} />}
{envelope.cc && envelope.cc.length > 0 && <Multilines title={t('mail.cc')} lines={envelope.cc} />}
{envelope.bcc && envelope.bcc.length > 0 && <Multilines title={t('mail.bcc')} lines={envelope.bcc} />}
{envelope.subject && (
<div className="flex space-x-2">
<span className="font-medium text-gray-400">Subject:</span>
<span className="font-medium text-gray-400">{t('mail.subject')}:</span>
<span>{envelope.subject}</span>
</div>
)}
{envelope.internal_date && (
<div className="flex space-x-2">
<span className="font-medium text-gray-400">Date:</span>
<span className="font-medium text-gray-400">{t('mail.date')}:</span>
<span>{formatTimestamp(envelope.internal_date)}</span>
</div>
)}
@@ -226,7 +229,7 @@ export function MailMessageView({
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete locally</TooltipContent>
<TooltipContent>{t('mail.delete')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
@@ -235,7 +238,7 @@ export function MailMessageView({
<Download className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Download .eml file</TooltipContent>
<TooltipContent>{t('mail.download')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
@@ -248,7 +251,7 @@ export function MailMessageView({
<MessageSquareMore className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View full thread</TooltipContent>
<TooltipContent>{t('mail.viewThread')}</TooltipContent>
</Tooltip>
</div>
</>
@@ -291,12 +294,12 @@ export function MailMessageView({
</div>
) : (
<span className="text-gray-500 text-xs italic">
Only non-inline attachments are shown here.
{t('mail.onlyNonInlineAttachments')}
</span>
);
})()
) : (
<span className="text-gray-500 text-xs">No attachments</span>
<span className="text-gray-500 text-xs">{t('mail.noAttachments')}</span>
)}
</div>
)}
+36 -33
View File
@@ -36,27 +36,28 @@ import { useQuery } from "@tanstack/react-query";
import { useSearchContext } from "./context";
import { toast } from "@/hooks/use-toast";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useTranslation } from "react-i18next";
const searchFilterSchema = z.object({
const getSearchFilterSchema = (t: (key: string) => string) => z.object({
text: z.string().optional().or(z.literal("")),
from: z
.string()
.email({ message: "Please enter a valid email address" })
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
to: z
.string()
.email({ message: "Please enter a valid email address" })
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
cc: z
.string()
.email({ message: "Please enter a valid email address" })
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
bcc: z
.string()
.email({ message: "Please enter a valid email address" })
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
has_attachment: z.boolean().optional(),
@@ -70,7 +71,7 @@ const searchFilterSchema = z.object({
message_id: z.string().optional().or(z.literal("")),
});
type SearchFilterForm = z.infer<typeof searchFilterSchema>;
type SearchFilterForm = z.infer<ReturnType<typeof getSearchFilterSchema>>;
interface Props {
@@ -98,11 +99,13 @@ const cleanEmpty = <T extends Record<string, any>>(obj: T): Partial<T> => {
};
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
const { t } = useTranslation()
const [showAdvanced, setShowAdvanced] = useState(false);
const [selectedAccountId, setSelectedAccountId] = useState<number | undefined>(undefined);
const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList();
const { selectedTags } = useSearchContext();
const searchFilterSchema = getSearchFilterSchema(t)
const form = useForm<SearchFilterForm>({
resolver: zodResolver(searchFilterSchema),
defaultValues: {
@@ -147,7 +150,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
onSubmit(cleaned);
} else {
toast({
title: 'Please select at least one search condition',
title: t('search.pleaseSelectAtLeastOne'),
});
}
}
@@ -180,12 +183,12 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
<SheetHeader className="p-4 pb-3 border-b shrink-0">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2">
Search Archived Emails
{t('search.searchArchivedEmails')}
</SheetTitle>
</div>
</SheetHeader>
<SheetDescription>
Full-text · Multi-account · Advanced filters
{t('search.fullTextMultiAccount')}
</SheetDescription>
<Form {...form}>
<form id="email-search-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
@@ -197,7 +200,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
render={({ field }) => (
<FormItem className="min-w-[180px]">
<div className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">Account:</FormLabel>
<FormLabel className="text-xs whitespace-nowrap">{t('search.account')}:</FormLabel>
<FormControl className="flex-1">
<VirtualizedSelect
options={accountsOptions}
@@ -208,17 +211,17 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
field.onChange(account_id);
}}
value={field.value?.toString() ?? ""}
placeholder="Select account"
placeholder={t('search.selectAccount')}
className="h-10 w-full"
noItemsComponent={
<div className="p-2">
<p className="text-xs">No active email account.</p>
<p className="text-xs">{t('search.noActiveEmailAccount')}</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: "/accounts" })}
>
Add Email Account
{t('search.addEmailAccount')}
</Button>
</div>
}
@@ -235,19 +238,19 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
render={({ field }) => (
<FormItem className="min-w-[180px]">
<div className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">Mailbox:</FormLabel>
<FormLabel className="text-xs whitespace-nowrap">{t('search.mailbox')}:</FormLabel>
<FormControl className="flex-1">
<VirtualizedSelect
options={mailboxesOptions}
isLoading={isMailboxesLoading}
onSelectOption={(values) => field.onChange(parseInt(values[0], 10))}
value={field.value?.toString() ?? ""}
placeholder="Select mailbox"
placeholder={t('search.selectMailbox')}
className="h-10 w-full"
noItemsComponent={
<div className="p-2">
<p className="text-xs">
No mailbox. Please select an account first.
{t('search.noMailboxSelectAccount')}
</p>
</div>
}
@@ -267,7 +270,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
<FormItem className="flex-1">
<FormControl>
<Input
placeholder="Search in subject, body, attachments..."
placeholder={t('search.searchInSubjectBody')}
className="h-11 text-base"
{...field}
/>
@@ -279,7 +282,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
<div className="flex gap-2 sm:ml-auto sm:self-center">
<Button type="submit" className="h-11 px-6" disabled={isLoading}>
{isLoading ? "Searching..." : <>Search</>}
{isLoading ? t('search.searchingButton') : <>{t('search.searchButton')}</>}
</Button>
<Button
type="button"
@@ -288,7 +291,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
onClick={() => setShowAdvanced(!showAdvanced)}
>
<Filter className="w-4 h-4 mr-1" />
Advanced
{t('search.advanced')}
{showAdvanced ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
</Button>
<Button
@@ -298,7 +301,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
onClick={() => { handleClear(); reset(); }}
>
<RotateCcw className="w-4 h-4 mr-1" />
Clear
{t('search.clear')}
</Button>
</div>
</div>
@@ -308,10 +311,10 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
name="since"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">Since:</FormLabel>
<FormLabel className="text-xs whitespace-nowrap">{t('search.since')}</FormLabel>
<FormControl>
<DatePicker
placeholder="Select a date"
placeholder={t('search.selectDate')}
selected={field.value}
onSelect={field.onChange}
/>
@@ -325,10 +328,10 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
name="before"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">Before:</FormLabel>
<FormLabel className="text-xs whitespace-nowrap">{t('search.before')}</FormLabel>
<FormControl>
<DatePicker
placeholder="Select a date"
placeholder={t('search.selectDate')}
selected={field.value}
onSelect={field.onChange}
/>
@@ -348,7 +351,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="attach" className="cursor-pointer text-sm font-normal">
Has attachments
{t('search.hasAttachment')}
</FormLabel>
</FormItem>
)}
@@ -361,7 +364,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
{/* Sender & Recipients */}
<AccordionItem value="people">
<AccordionTrigger className="text-sm">
Sender / Recipients
{t('search.sender')} / {t('search.recipient')}
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 pt-2">
@@ -373,7 +376,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
render={({ field }) => (
<FormItem>
<FormLabel className="capitalize text-xs">
{key === 'from' ? 'From' : key === 'to' ? 'To' : key.toUpperCase()}
{key === 'from' ? t('search.from') : key === 'to' ? t('search.to') : key === 'cc' ? t('search.cc') : t('search.bcc')}:
</FormLabel>
<FormControl>
<Input
@@ -392,7 +395,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
</AccordionItem>
<AccordionItem value="attachment">
<AccordionTrigger className="text-sm">
Attachments & Size
{t('search.attachmentsSize')}
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
@@ -401,7 +404,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
name="attachment_name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Attachment name</FormLabel>
<FormLabel className="text-xs">{t('search.attachmentName')}:</FormLabel>
<FormControl>
<Input placeholder="invoice.pdf" className="h-9" {...field} />
</FormControl>
@@ -414,7 +417,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
name="min_size"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Minimum (bytes)</FormLabel>
<FormLabel className="text-xs">{t('search.minSize')} (bytes):</FormLabel>
<FormControl>
<Input type="number" placeholder="1MB = 1048576" className="h-9" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
@@ -427,7 +430,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
name="max_size"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Maximum (bytes)</FormLabel>
<FormLabel className="text-xs">{t('search.maxSize')} (bytes):</FormLabel>
<FormControl>
<Input type="number" placeholder="10MB = 10485760" className="h-9" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
@@ -440,7 +443,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
</AccordionItem>
<AccordionItem value="ids">
<AccordionTrigger className="text-sm">
Message-ID
{t('search.messageId')}
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
@@ -453,7 +456,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
<Input placeholder="<abc123@example.com>" className="h-9" {...field} />
</FormControl>
<FormDescription className="text-xs">
Original email Message-ID header
{t('search.originalMessageIdHeader')}
</FormDescription>
</FormItem>
)}
+4 -2
View File
@@ -25,6 +25,7 @@ import React from 'react';
import { useAvailableTags } from '@/hooks/use-available-tags';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '@/components/ui/label';
import { useTranslation } from 'react-i18next';
interface EnvelopeTagsProps {
selectedTags: string[];
@@ -32,6 +33,7 @@ interface EnvelopeTagsProps {
}
export function EnvelopeTags({ selectedTags, onTagToggle }: EnvelopeTagsProps) {
const { t } = useTranslation()
const [open, setOpen] = React.useState(true);
const {
@@ -67,7 +69,7 @@ export function EnvelopeTags({ selectedTags, onTagToggle }: EnvelopeTagsProps) {
<CollapsibleTrigger className="flex w-full items-center justify-between text-sm font-medium hover:text-primary transition-colors">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4" />
Tags
{t('mail.tags')}
{selectedTags.length > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-xs">
{selectedTags.length}
@@ -79,7 +81,7 @@ export function EnvelopeTags({ selectedTags, onTagToggle }: EnvelopeTagsProps) {
<CollapsibleContent className="space-y-0">
{sortedTags.length === 0 ? (
<p className="py-2 pl-2 text-sm text-muted-foreground">No tags yet</p>
<p className="py-2 pl-2 text-sm text-muted-foreground">{t('mail.noTagsYet')}</p>
) : (
<ScrollArea className="h-[45rem] w-full pr-4 -mr-4">
{sortedTags.map(({ tag: facet, count }) => {
+21 -14
View File
@@ -16,10 +16,8 @@
// 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 { useState } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { ChevronDown, ChevronUp, Loader2, MessageSquareText } from 'lucide-react';
import {
@@ -34,6 +32,9 @@ import { Skeleton } from '@/components/ui/skeleton';
import { get_thread_messages } from '@/api/mailbox/envelope/api';
import { MailMessageView } from './mail-message-view';
import { useSearchContext } from './context';
import { useTranslation } from 'react-i18next';
import { formatTimestamp } from '@/lib/utils';
import { format } from 'date-fns';
interface MailThreadDialogProps {
open: boolean;
@@ -43,6 +44,7 @@ interface MailThreadDialogProps {
export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps) {
const { currentEnvelope } = useSearchContext();
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const { t } = useTranslation();
const threadId = currentEnvelope?.thread_id;
const accountId = currentEnvelope?.account_id;
@@ -82,30 +84,32 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-full p-0 max-h-full flex flex-col md:max-w-3xl lg:max-w-4xl">
<DialogContent className="w-full max-width-full p-0 max-h-full flex flex-col md:max-w-3xl lg:max-w-4xl">
{/* Header */}
<DialogHeader className="p-4 pb-3 border-b shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<MessageSquareText className="w-5 h-5" />
<div className='text-sm'>Thread ({totalCount} {totalCount === 1 ? 'message' : 'messages'})</div>
<div className="text-sm">
{t('search.thread.title', { count: totalCount })}
</div>
</DialogTitle>
</div>
</DialogHeader>
{/* Body - Scrollable */}
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{isLoading && <ThreadSkeleton />}
{isError && (
<div className="text-center text-destructive text-sm">
Failed to load thread: {(error as Error)?.message}
{t('search.thread.error')}: {(error as Error)?.message}
</div>
)}
{!isLoading && allMessages.length === 0 && (
<div className="text-center text-muted-foreground text-sm">
No messages in this thread.
{t('search.thread.empty')}
</div>
)}
@@ -113,11 +117,13 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
.sort((a, b) => a.internal_date - b.internal_date)
.map((msg) => {
const isExpanded = expandedIds.has(msg.id);
const preview = msg.text?.slice(0, 120) + (msg.text?.length > 120 ? '...' : '');
const preview =
msg.text?.slice(0, 120) +
(msg.text?.length > 120 ? '...' : '');
const date = new Date(msg.internal_date);
const formattedDate = isNaN(date.getTime())
? 'Invalid Date'
: format(date, 'MMM d, yyyy h:mm a');
? t('search.thread.invalidDate')
: format(date, 'yyyy-MM-dd HH:mm:ss');
return (
<Card
@@ -138,7 +144,7 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
</span>
</div>
<p className="font-medium mt-1 text-sm">
{msg.subject || '(No subject)'}
{msg.subject || t('search.thread.noSubject')}
</p>
{!isExpanded && preview && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
@@ -146,6 +152,7 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
</p>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formattedDate}</span>
{isExpanded ? (
@@ -184,10 +191,10 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
{isFetchingNextPage ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Loading more...
{t('search.thread.loadingMore')}
</>
) : (
'Load more'
t('search.thread.loadMore')
)}
</Button>
</div>
@@ -214,4 +221,4 @@ function ThreadSkeleton() {
))}
</div>
);
}
}