feat: add attachment search view

This commit is contained in:
rustmailer
2026-04-19 01:01:46 +08:00
parent 19b5168960
commit 7dd722f9b8
96 changed files with 36331 additions and 28970 deletions
+1 -1
View File
@@ -102,7 +102,7 @@ export function AccountPopover() {
size="sm"
variant="outline"
className={cn(
'h-8 gap-1.5 px-3 rounded-none',
'h-6 gap-1.5 px-3 rounded-none',
selectedIds.length > 0 &&
'bg-primary/10 border-primary text-primary'
)}
+1 -1
View File
@@ -62,7 +62,7 @@ export function MailFilterPopover() {
size="sm"
variant="outline"
className={cn(
'h-8 rounded-none px-3 gap-1.5 transition-colors',
'h-6 rounded-none px-3 gap-1.5 transition-colors',
activeCount > 0 && 'bg-primary/10 text-primary hover:bg-primary/20'
)}
>
+1 -1
View File
@@ -42,7 +42,7 @@ export function FilterResetButton() {
size="sm"
onClick={() => setFilter(q ? { q } : {})}
className={cn(
"h-8 px-2 text-xs gap-1.5 font-normal",
"h-6 px-2 text-xs gap-1.5 font-normal",
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
)}
title={t('search_reset.tooltip')}
+4 -4
View File
@@ -21,7 +21,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { FixedHeader } from '@/components/layout/fixed-header';
import { Main } from '@/components/layout/main';
import { useSearchMessages } from '@/hooks/use-search-messages';
import { EnvelopeListPagination } from '@/components/pagination';
import { AttachmentListPagination } from '@/components/pagination';
import React from 'react';
import { EmailEnvelope } from '@/api';
import { MailDisplayDrawer } from './mail-display-dialog';
@@ -36,7 +36,7 @@ import { SortingState } from '@tanstack/react-table';
import { MailBoxDeleteDialog } from './delete-mailbox-dialog';
import { UpdateTagsDialog } from './bulk-add-tag-dialog';
export default function Search() {
export default function EmailSearch() {
const { t } = useTranslation()
const [selectedEnvelope, setSelectedEnvelope] = React.useState<EmailEnvelope | undefined>(undefined);
const [open, setOpen] = useDialogState<SearchDialogType>(null)
@@ -125,7 +125,7 @@ export default function Search() {
setSortBy={setSortBy}
setSortOrder={setSortOrder}
/>
{total > 0 && <EnvelopeListPagination
{total > 0 && <AttachmentListPagination
totalItems={total}
hasNextPage={() => page < totalPages}
pageIndex={page - 1}
@@ -156,7 +156,7 @@ export default function Search() {
/>
<UpdateTagsDialog
key='edit-tags-dialog'
key='update-tags-dialog'
open={open === 'update-tags'}
onOpenChange={() => setOpen('update-tags')}
/>
+48 -107
View File
@@ -86,110 +86,54 @@ export function MailListTable({
maxSize: 25,
},
{
accessorKey: "account_email",
header: t('search.account'),
accessorKey: "source",
header: t('search.source'),
cell: ({ row }) => {
const { from, account_email, mailbox_name, account_id, mailbox_id } = row.original;
const { setFilter } = useSearchMessages();
const { account_email, account_id } = row.original;
const accountPrefix = account_email.split('@')[0];
return (
<div
className="group relative flex items-center w-full h-full px-2 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setFilter((prev: any) => ({
...prev,
account_ids: [account_id],
mailbox_ids: undefined
}))
}}
>
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
<div className="absolute inset-x-2 bottom-0.5 h-[1px] bg-primary scale-x-0 group-hover:scale-x-100 transition-transform duration-200 origin-left" />
<span className="truncate flex-1 min-w-0 group-hover:text-primary transition-colors">
<LongText className='text-xs'>{account_email}</LongText>
</span>
</div>
);
},
meta: { className: 'w-[150px]' },
minSize: 150, maxSize: 150,
},
{
accessorKey: "mailbox_name",
header: t('search.mailbox'),
cell: ({ row }) => {
const { setFilter } = useSearchMessages();
const { mailbox_name, mailbox_id, account_id, tags } = row.original;
<div className="flex flex-col py-1.5 min-w-0 group">
<div
className="cursor-pointer hover:text-primary transition-colors flex items-center gap-1.5"
onClick={(e) => {
e.stopPropagation();
setFilter((prev: any) => ({ ...prev, from: from }));
}}
>
<LongText className="text-xs truncate">
{from}
</LongText>
</div>
if (!mailbox_name) return null;
const safeTags = tags ?? [];
const visibleTags = safeTags.slice(0, 2);
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
<span
className="truncate max-w-[90px] hover:text-primary cursor-pointer transition-colors"
title={account_email}
onClick={(e) => {
e.stopPropagation();
setFilter((prev: any) => ({ ...prev, account_ids: [account_id], mailbox_ids: undefined }));
}}
>
{accountPrefix}
</span>
return (
<div
className="group relative flex items-center w-full min-w-0 h-full px-2 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setFilter((prev: any) => ({
...prev,
account_ids: [account_id],
mailbox_ids: [mailbox_id]
}))
}}
>
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
<div className="absolute inset-x-2 bottom-0.5 h-[1px] bg-primary scale-x-0 group-hover:scale-x-100 transition-transform duration-200 origin-left" />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[11px] truncate font-medium leading-none group-hover:text-primary transition-colors">
<span className="shrink-0 opacity-40">/</span>
<span
className="truncate max-w-[70px] hover:text-primary cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation();
setFilter((prev: any) => ({ ...prev, account_ids: [account_id], mailbox_ids: [mailbox_id] }));
}}
>
{mailbox_name}
</span>
<div className="flex flex-wrap gap-1 mt-1">
{visibleTags.map((tag, i) => (
<span key={i} className="px-1.5 py-0.5 rounded-sm bg-primary/10 text-primary text-[9px] font-medium leading-none border border-primary/20 whitespace-nowrap">
{tag}
</span>
))}
{safeTags.length > 2 && (
<span className="px-1.5 py-0.5 rounded-sm bg-gray-100 text-gray-500 text-[9px] font-medium leading-none border border-gray-200">
+{safeTags.length - 2}
</span>
)}
</div>
</div>
</div>
);
},
meta: { className: 'text-xs' },
maxSize: 120,
},
{
accessorKey: "from",
header: t('search.from'),
cell: ({ row }) => {
const fromEmail = row.original.from;
const { setFilter } = useSearchMessages();
return (
<div
className="group relative flex items-center w-full min-w-0 h-full px-2 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setFilter((prev: Record<string, any>) => ({ ...prev, from: fromEmail }))
}}
>
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
<div className="absolute inset-x-2 bottom-0.5 h-[1px] bg-primary scale-x-0 group-hover:scale-x-100 transition-transform duration-200 origin-left" />
<LongText className="text-xs flex-1 truncate group-hover:text-primary transition-colors">
{fromEmail}
</LongText>
</div>
);
},
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 300,
},
{
accessorKey: "to",
@@ -224,20 +168,17 @@ export function MailListTable({
</div>
);
},
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 200,
meta: { className: 'text-left text-xs' }
},
{
accessorKey: "subject",
header: t('search.subject'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
meta: { className: 'text-left text-xs' },
maxSize: 450,
meta: { className: 'text-left text-xs' }
},
{
id: "text_preview",
header: () => null,
header: t('search.preview'),
cell: ({ row }) => {
const preview = row.original.preview
@@ -265,18 +206,18 @@ export function MailListTable({
</HoverCard>
)
},
meta: { className: "text-center max-w-[80px]" },
minSize: 36,
maxSize: 36,
meta: { className: "text-center text-xs" },
enableSorting: false,
minSize: 100,
maxSize: 100,
},
{
id: "attachment_count",
header: () => <Paperclip size={16} />,
cell: ({ row }) => <span className='text-xs'>{row.original.regular_attachment_count}</span>,
meta: { className: 'text-left text-xs' },
minSize: 40,
maxSize: 40
minSize: 30,
maxSize: 30,
},
{
accessorKey: 'size',
@@ -285,8 +226,8 @@ export function MailListTable({
),
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
meta: { className: 'text-left text-xs' },
minSize: 100,
maxSize: 100,
minSize: 80,
maxSize: 80,
},
{
accessorKey: 'date',
@@ -308,15 +249,15 @@ export function MailListTable({
)
},
meta: { className: 'text-left text-xs' },
minSize: 130,
maxSize: 130,
minSize: 100,
maxSize: 100,
},
{
id: 'actions',
header: t('users.columns.actions'),
cell: DataTableRowActions,
meta: { className: 'text-right text-xs' },
minSize: 50,
minSize: 60,
maxSize: 60,
},
]
+1 -1
View File
@@ -271,7 +271,7 @@ export function MailboxPopover() {
variant="outline"
disabled={disabled}
className={cn(
'h-8 rounded-none px-3 gap-1.5 transition-colors',
'h-6 rounded-none px-3 gap-1.5 transition-colors',
selectedMailboxIds.length > 0 && 'bg-primary/10 text-primary border-primary/20'
)}
>
@@ -129,7 +129,7 @@ export function MoreFiltersPopover() {
variant="outline"
size="sm"
className={cn(
"h-8 gap-2 px-3 rounded-none border-l-0",
"h-6 gap-2 px-3 rounded-none border-l-0",
activeCount > 0 && "bg-primary/10 border-primary text-primary"
)}
>
@@ -218,7 +218,7 @@ export function MoreFiltersPopover() {
/>
<MetadataSelectorField
label={t('search_more.content_types')}
label={t('search_more.content_type')}
value={localState.attachment_content_type}
options={meta?.content_types || []}
isLoading={metaLoading}
+1 -2
View File
@@ -98,7 +98,7 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
return (
<div className="flex flex-1 flex-col gap-0.5">
{children && (<>{children(table)}</>)}
<ScrollArea className='h-[calc(100vh-13rem)] rounded-md border' orientation='both'>
<ScrollArea className='h-[calc(100vh-16rem)] rounded-md border' orientation='both'>
<ShadcnTable>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -159,7 +159,6 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
</TableCell>
</TableRow>
)}
</TableBody>
</ShadcnTable>
</ScrollArea>
+18 -17
View File
@@ -16,27 +16,28 @@ type DataTableToolbarProps<TData> = {
export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
return (
<div className="flex flex-col gap-1 px-1 py-1 lg:flex-row lg:items-center lg:gap-1">
<div className="flex-1">
<TextSearchInput />
</div>
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap lg:justify-end">
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap">
<AccountPopover />
<MailboxPopover />
<MailFilterPopover />
<TagFilterPopover />
<TimePopover />
<MoreFiltersPopover />
<DataTableViewOptions table={table} />
<div className="flex flex-col gap-1 p-1 bg-background">
<div className="mb-4 flex items-center justify-center w-full">
<div className="w-full max-w-3xl">
<TextSearchInput />
</div>
<div className="flex-shrink-0 ml-auto lg:ml-0">
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-1">
<div className="flex items-center gap-2 flex-wrap w-full sm:w-auto">
<div className="flex items-center gap-1.5 flex-wrap">
<AccountPopover />
<MailboxPopover />
<MailFilterPopover />
<TagFilterPopover />
<MoreFiltersPopover />
</div>
<FilterResetButton />
</div>
<div className="flex items-center gap-1 shrink-0">
<TimePopover />
<DataTableViewOptions table={table} />
</div>
</div>
</div>
)
@@ -50,7 +50,7 @@ export function DataTableViewOptions<TData>({
<Button
variant='outline'
size='sm'
className='ms-auto hidden h-8 lg:flex rounded-none'
className='ms-auto hidden h-6 lg:flex rounded-none'
>
<MixerHorizontalIcon className='size-4' />
{t('search_view.button_label')}
@@ -98,7 +98,7 @@ export function TagFilterPopover() {
size="sm"
variant="outline"
className={cn(
'h-8 gap-1.5 px-3 rounded-none',
'h-6 gap-1.5 px-3 rounded-none',
selectedTags.length > 0 &&
'bg-primary/10 border-primary text-primary'
)}
+46 -25
View File
@@ -83,17 +83,23 @@ export function TimePopover() {
size="sm"
variant="outline"
className={cn(
'h-8 rounded-none px-3 gap-1.5 transition-colors',
'h-6 rounded-none px-3 gap-1.5 transition-colors max-w-full',
(since || before) && 'bg-primary/10 text-primary hover:bg-primary/20'
)}
>
<CalendarRange className="h-4 w-4" />
{label(since, before)}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
<CalendarRange className="h-4 w-4 shrink-0" />
<span className="truncate max-w-[120px] sm:max-w-none">
{label(since, before)}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[530px] p-4 space-y-6">
<PopoverContent
align="end"
sideOffset={8}
className="w-[92vw] sm:w-[420px] max-w-[420px] p-4 space-y-6"
>
<Section title={t('time.recent_range')}>
<div className="space-y-4 w-full">
<div className="flex flex-wrap gap-2">
@@ -109,22 +115,26 @@ export function TimePopover() {
))}
</div>
<div className="flex items-center gap-2 pt-3 border-t border-border/50">
<span className="text-[10px] uppercase font-bold opacity-40 shrink-0">{t('time.recent_prefix')}</span>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pt-3 border-t border-border/50">
<span className="text-[10px] uppercase font-bold opacity-40 shrink-0">
{t('time.recent_prefix')}
</span>
<Input
type="number"
min={1}
placeholder="10"
className="h-8 w-20 text-xs"
className="h-8 w-full sm:w-20 text-xs"
value={customDays}
onChange={e => setCustomDays(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleApplyRecent()}
/>
<span className="text-xs text-muted-foreground shrink-0">{t('time.days_ago_to_now')}</span>
<span className="text-xs text-muted-foreground shrink-0">
{t('time.days_ago_to_now')}
</span>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 ml-auto text-xs"
className="h-8 px-3 sm:ml-auto text-xs w-full sm:w-auto"
onClick={handleApplyRecent}
>
{t('time.apply')}
@@ -132,6 +142,7 @@ export function TimePopover() {
</div>
</div>
</Section>
<Section title={t('time.historical')}>
<div className="flex flex-wrap gap-2 w-full">
{[1, 2, 3, 5, 10].map(y => (
@@ -148,23 +159,33 @@ export function TimePopover() {
))}
</div>
</Section>
<Section title={t('time.absolute_range')}>
<div className="flex gap-3 w-full">
<div className="flex-1 min-w-0 space-y-1.5">
<span className="text-[10px] pl-1 opacity-50 font-medium">{t('time.since').toUpperCase()}</span>
<DatePicker
placeholder={t('time.start_date')}
selected={since ? new Date(since) : undefined}
onSelect={(date) => setSince(date?.getTime())}
/>
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-3 w-full">
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
{t('time.since').toUpperCase()}:
</span>
<div className="flex-1">
<DatePicker
placeholder={t('time.start_date')}
selected={since ? new Date(since) : undefined}
onSelect={(date) => setSince(date?.getTime())}
/>
</div>
</div>
<div className="flex-1 min-w-0 space-y-1.5">
<span className="text-[10px] pl-1 opacity-50 font-medium">{t('time.before').toUpperCase()}</span>
<DatePicker
placeholder={t('time.end_date')}
selected={before ? new Date(before) : undefined}
onSelect={(date) => setBefore(date?.getTime())}
/>
<div className="flex items-center gap-3 w-full">
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
{t('time.before').toUpperCase()}:
</span>
<div className="flex-1">
<DatePicker
placeholder={t('time.end_date')}
selected={before ? new Date(before) : undefined}
onSelect={(date) => setBefore(date?.getTime())}
/>
</div>
</div>
</div>
</Section>