mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: add attachment search view
This commit is contained in:
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user