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:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
)}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user