feat(search): integrate mailbox directory tree into search interface

This commit is contained in:
rustmailer
2026-03-15 03:36:02 +08:00
parent 2b10d201ee
commit af0f47c0e3
27 changed files with 374 additions and 2293 deletions
@@ -1,11 +0,0 @@
import { AccountPopover } from './account-popover'
import { MailboxPopover } from './mailbox-popover'
export function AccountMailboxFilter() {
return (
<>
<AccountPopover />
<MailboxPopover />
</>
)
}
+1 -16
View File
@@ -138,7 +138,6 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
'flex items-center gap-x-2'
)}
>
{/* Clear Selection */}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -153,19 +152,12 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
</Button>
</TooltipTrigger>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
{/* 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">
{t('search.bulkActions.selected', { count: selectedCount })}
</span>
</div>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
@@ -176,18 +168,14 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
className="gap-1"
>
<Upload className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
{t('restore_message.restore_to_imap', 'Restore Mail')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{t('search.bulkActions.restoreDesc')}
{t('restore_message.restore_to_imap', 'Restore Mail')}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
{/* Delete */}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -197,9 +185,6 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
className="gap-1"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
{t('search.bulkActions.delete')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
+5 -1
View File
@@ -21,7 +21,7 @@ import React from 'react'
import { EmailEnvelope } from '@/api'
import { SortingState } from '@tanstack/react-table'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore' | 'delete-mailbox'
interface SearchContextType {
open: SearchDialogType | null
@@ -32,6 +32,10 @@ interface SearchContextType {
setToDelete: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
selected: Map<number, Set<number>>
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
deleteMailboxId: string | undefined
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
selectedAccountId: number | undefined
setSelectedAccountId: React.Dispatch<React.SetStateAction<number | undefined>>
selectedTags: string[]
sorting: SortingState
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
@@ -0,0 +1,105 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { IconAlertTriangle } from '@tabler/icons-react';
import { toast } from '@/hooks/use-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { delete_mailbox } from '@/api/mailbox/api';
import { useSearchContext } from './context';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
const queryClient = useQueryClient();
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useSearchContext();
const { t } = useTranslation();
const deleteMutation = useMutation({
mutationFn: ({ accountId, mailboxId }: { accountId: number; mailboxId: string }) =>
delete_mailbox(accountId, mailboxId),
retry: false,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['search-mailboxes', selectedAccountId] });
onOpenChange(false);
setDeleteMailboxId(undefined);
toast({
title: t('mailbox.deleteMailboxDialog.successTitle'),
description: t('mailbox.deleteMailboxDialog.successDesc'),
});
},
onError: (error: any) => {
toast({
title: t('mailbox.deleteMailboxDialog.errorTitle'),
description: error.message || "Delete failed",
variant: 'destructive',
});
},
});
const handleDelete = () => {
if (selectedAccountId && deleteMailboxId) {
deleteMutation.mutate({
accountId: selectedAccountId,
mailboxId: deleteMailboxId
});
}
};
const isLoading = deleteMutation.isPending;
return (
<ConfirmDialog
open={open}
onOpenChange={(isOpen) => {
onOpenChange(isOpen);
if (!isOpen) setDeleteMailboxId(undefined);
}}
handleConfirm={handleDelete}
className="max-w-xl"
isLoading={isLoading}
title={
<span className="text-destructive">
<IconAlertTriangle
className="mr-1 inline-block stroke-destructive"
size={18}
/>{' '}
{t('mailbox.deleteMailboxDialog.title')}
</span>
}
desc={
<div className="space-y-4">
<p className="mb-2">
{t('mailbox.deleteMailboxDialog.desc')}
</p>
<Alert variant="destructive">
<AlertTitle>{t('mailbox.deleteMailboxDialog.warningTitle')}</AlertTitle>
<AlertDescription>{t('mailbox.deleteMailboxDialog.warningDesc')}</AlertDescription>
</Alert>
</div>
}
confirmText={t('mailbox.deleteMailboxDialog.confirm')}
destructive
/>
);
}
+13
View File
@@ -33,6 +33,7 @@ import { useTranslation } from 'react-i18next';
import { RestoreMessageDialog } from './restore-message-dialog';
import { MailListTable } from './mail-list-table';
import { SortingState } from '@tanstack/react-table';
import { MailBoxDeleteDialog } from './delete-mailbox-dialog';
export default function Search() {
const { t } = useTranslation()
@@ -42,6 +43,8 @@ export default function Search() {
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
const [selectedAccountId, setSelectedAccountId] = React.useState<number | undefined>(undefined);
const {
emails,
@@ -90,6 +93,10 @@ export default function Search() {
setSorting,
filter,
setFilter,
deleteMailboxId,
setDeleteMailboxId,
selectedAccountId,
setSelectedAccountId,
handleTagToggle
}}
>
@@ -152,6 +159,12 @@ export default function Search() {
open={open === 'restore'}
onOpenChange={() => setOpen('restore')}
/>
<MailBoxDeleteDialog
key='mailbox-delete'
open={open === 'delete-mailbox'}
onOpenChange={() => setOpen('delete-mailbox')}
/>
</SearchProvider>
</Main>
</>
+1 -1
View File
@@ -252,7 +252,7 @@ export function MailList({
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('restore_message.restore_to_imap', 'Restore Mail')}
{t('restore_message.restore_to_imap')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
+331 -214
View File
@@ -1,263 +1,380 @@
import * as React from 'react'
import { ChevronDown, Folders, X } from 'lucide-react'
import { useQueries } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import * as React from 'react';
import {
ChevronDown, Folders, X, TreeDeciduous, FolderIcon,
MoreVertical, Trash2, Search,
Check
} from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { animated, useSpring } from '@react-spring/web';
import { styled } from '@mui/material/styles';
import Collapse from '@mui/material/Collapse';
import { TransitionProps } from '@mui/material/transitions';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
TreeItemCheckbox,
TreeItemContent,
TreeItemDragAndDropOverlay,
TreeItemIcon,
TreeItemIconContainer,
TreeItemLabel,
TreeItemProvider,
TreeItemRoot,
useTreeItemModel,
} from '@mui/x-tree-view';
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
import { useTreeItem, UseTreeItemParameters } from '@mui/x-tree-view/useTreeItem';
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { useSearchContext } from './context'
import { list_mailboxes } from '@/api/mailbox/api';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import { useSearchContext } from './context';
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree';
const CustomCollapse = styled(Collapse)({ padding: 0 });
const AnimatedCollapse = animated(CustomCollapse);
function TransitionComponent(props: TransitionProps) {
const style = useSpring({
to: {
opacity: props.in ? 1 : 0,
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
},
});
return <AnimatedCollapse style={style} {...props} />;
}
interface CustomTreeItemProps
extends Omit<UseTreeItemParameters, 'rootRef'>,
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
interface CustomLabelProps {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode;
id: string;
icon?: React.ElementType;
expandable?: boolean;
onDelete: (id: string) => void;
}
function CustomLabel({
expandable,
exists,
attributes,
children,
id,
onDelete,
...other
}: CustomLabelProps) {
const { t } = useTranslation()
return (
<TreeItemLabel
{...other}
sx={{
display: 'flex',
alignItems: 'center',
}}
>
<FolderIcon className="mr-2" />
<span className="font-medium text-sm text-inherit">
{children}
</span>
<div className="ml-auto flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-20">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
}}
onSelect={(e) => {
e.preventDefault();
onDelete(id);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t('common.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TreeItemLabel>
);
}
export function MailboxPopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const { minimalList = [] } = useMinimalAccountList()
const { t } = useTranslation();
const { filter, setFilter, setOpen, setDeleteMailboxId, setSelectedAccountId } = useSearchContext();
const { minimalList = [] } = useMinimalAccountList();
const [search, setSearch] = React.useState('')
const [localOpen, setLocalOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const accountIds: number[] = filter.account_ids ?? []
const selectedMailboxIds: number[] = filter.mailbox_ids ?? []
const accountIds: number[] = filter.account_ids ?? [];
const selectedMailboxIds: number[] = filter.mailbox_ids ?? [];
const { mailboxes, isLoading } = useQueries({
queries: accountIds.map(id => ({
queryKey: ['search-mailboxes', id],
queryFn: () => list_mailboxes(id, false),
enabled: accountIds.length > 0,
})),
combine: results => ({
mailboxes: results.flatMap(r => r.data ?? []),
isLoading: results.some(r => r.isLoading),
}),
})
const [localSelectedIds, setLocalSelectedIds] = React.useState<number[]>([]);
const [activeAccountId, setActiveAccountId] = React.useState<number | undefined>(undefined);
const toggleMailbox = (id: number) => {
setFilter(prev => {
const next = { ...prev }
const set = new Set<number>(next.mailbox_ids ?? [])
const queryClient = useQueryClient();
set.has(id) ? set.delete(id) : set.add(id)
React.useEffect(() => {
if (localOpen) {
const globalMailboxIds = filter.mailbox_ids ?? [];
setLocalSelectedIds(globalMailboxIds);
const ids = Array.from(set)
if (ids.length === 0) delete next.mailbox_ids
else next.mailbox_ids = ids
return next
})
}
const clearAllMailboxes = () => {
setFilter(prev => {
const next = { ...prev }
delete next.mailbox_ids
return next
})
}
const grouped = React.useMemo(() => {
const q = search.trim().toLowerCase()
const map = new Map<number, MailboxData[]>()
for (const mb of mailboxes) {
if (q && !mb.name.toLowerCase().includes(q)) continue
if (!map.has(mb.account_id)) map.set(mb.account_id, [])
map.get(mb.account_id)!.push(mb)
const currentAccountIds = filter.account_ids ?? [];
if (currentAccountIds.length > 0) {
if (!activeAccountId || !currentAccountIds.includes(activeAccountId)) {
setActiveAccountId(currentAccountIds[0]);
}
} else {
setActiveAccountId(undefined);
}
}
}, [localOpen, activeAccountId, filter.account_ids, filter.mailbox_ids]);
for (const list of map.values()) {
list.sort((a, b) => {
const aSel = selectedMailboxIds.includes(a.id)
const bSel = selectedMailboxIds.includes(b.id)
if (aSel && !bSel) return -1
if (!aSel && bSel) return 1
return a.name.localeCompare(b.name)
})
}
const { data: activeMailboxes = [], isLoading: activeIsLoading } = useQuery({
queryKey: ['search-mailboxes', activeAccountId],
queryFn: () => list_mailboxes(activeAccountId!, false),
enabled: !!activeAccountId,
});
return Array.from(map.entries())
}, [mailboxes, search, selectedMailboxIds])
const treeData = React.useMemo(() => {
const filtered = search.trim()
? activeMailboxes.filter(m => m.name.toLowerCase().includes(search.toLowerCase()))
: activeMailboxes;
return buildTree(filtered);
}, [activeMailboxes, search]);
const defaultOpen = grouped
.filter(([, boxes]) =>
boxes.some(m => selectedMailboxIds.includes(m.id))
)
.map(([id]) => id.toString())
const disabled = accountIds.length === 0;
const getAccountEmail = (id: number) =>
minimalList.find(a => a.id === id)?.email ?? ''
const handleApply = () => {
setFilter(prev => ({
...prev,
mailbox_ids: localSelectedIds.length > 0 ? localSelectedIds : undefined
}));
setLocalOpen(false);
};
const disabled = accountIds.length === 0
const handleDeleteClick = (id: string) => {
console.log("delete=", id);
setDeleteMailboxId(id);
setSelectedAccountId(activeAccountId);
setOpen('delete-mailbox');
};
const CustomTreeItem = React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps,
ref: React.Ref<HTMLLIElement>,
) {
const { id, itemId, label, disabled, children, ...other } = props;
const {
getContextProviderProps,
getRootProps,
getContentProps,
getLabelProps,
getIconContainerProps,
getCheckboxProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
return (
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)} className="group">
<TreeItemContent {...getContentProps()} sx={{ paddingY: '2px' }}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} sx={{
color: 'hsl(var(--muted-foreground) / 0.4)',
'&.Mui-checked': {
color: 'hsl(var(--primary))',
},
}} />
<CustomLabel
{...getLabelProps({
exists: item.exists,
id: item.id,
onDelete: handleDeleteClick,
attributes: item.attributes,
expandable: status.expandable && status.expanded,
})}
/>
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
</TreeItemContent>
{children && <TransitionComponent {...getGroupTransitionProps()} />}
</TreeItemRoot>
</TreeItemProvider>
);
});
return (
<Popover>
<Popover open={localOpen} onOpenChange={setLocalOpen} >
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
disabled={disabled}
className={cn(
'h-8 rounded-none px-3 gap-1.5',
selectedMailboxIds.length > 0 &&
'bg-primary/10 text-primary'
'h-8 rounded-none px-3 gap-1.5 transition-colors',
selectedMailboxIds.length > 0 && 'bg-primary/10 text-primary border-primary/20'
)}
>
<Folders className="h-4 w-4" />
{t('search_mailbox.label')}
<span className="max-w-[100px] truncate">{t('search_mailbox.label')}</span>
{selectedMailboxIds.length > 0 && (
<span className="ml-1 text-xs opacity-70">
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{selectedMailboxIds.length}
</span>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="min-w-[260px] w-fit max-w-[620px] p-1">
<div className="p-1 pb-2">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('search_mailbox.search_placeholder')}
className="h-8 text-sm"
/>
</div>
{selectedMailboxIds.length > 0 && (
<div className="px-1 pb-2">
<PopoverContent
align="start"
className="w-[740px] max-w-[95vw] p-0 flex flex-col h-[480px] shadow-xl border-muted"
>
<div className="flex items-center gap-2 p-2 border-b bg-muted/10">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('search_mailbox.search_placeholder')}
className="h-9 pl-8 text-xs bg-background"
/>
</div>
{localSelectedIds.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearAllMailboxes}
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive transition-colors"
onClick={() => setLocalSelectedIds([])}
className="h-9 text-xs text-destructive hover:bg-destructive/10"
>
<X className="mr-2 h-3.5 w-3.5" />
{t('search_mailbox.clear_mailboxes')} ({selectedMailboxIds.length})
<X className="mr-1.5 h-3 w-3" />
{t('common.clear')}
</Button>
</div>
)}
<ScrollArea className="h-96 p-1">
{disabled ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('search_mailbox.select_account_first')}
</p>
) : isLoading ? (
<div className="space-y-2 p-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-4 rounded bg-muted animate-pulse"
/>
))}
</div>
) : grouped.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('search_mailbox.no_mailbox_found')}
</p>
) : (
<Accordion
type="multiple"
defaultValue={defaultOpen}
className="space-y-1"
>
{grouped.map(([accountId, boxes]) => {
const selectedCount = boxes.filter(b =>
selectedMailboxIds.includes(b.id)
).length
)}
</div>
return (
<AccordionItem
key={accountId}
value={accountId.toString()}
>
<AccordionTrigger className="text-xs px-2 py-1.5">
<span className="truncate">
{getAccountEmail(accountId)}
<div className="flex flex-1 min-h-0">
<div className="w-64 border-r bg-muted/20 flex flex-col">
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{accountIds.map(id => {
const acc = minimalList.find(a => a.id === id);
const isActive = activeAccountId === id;
const cachedData = queryClient.getQueryData<any[]>(['search-mailboxes', id]);
const count = cachedData?.filter(m => localSelectedIds.includes(m.id)).length ?? 0;
return (
<button
key={id}
onClick={() => setActiveAccountId(id)}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-left rounded-md transition-all",
isActive
? "bg-background shadow-sm text-primary ring-1 ring-black/5"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
<span className="text-xs truncate font-medium">
{acc?.email}
</span>
{selectedCount > 0 && (
<span className="ml-2 text-[10px] text-primary">
{selectedCount}
{count > 0 && (
<span className="text-[10px] font-bold bg-primary/10 px-1.5 py-0.5 rounded-full">
{count}
</span>
)}
</AccordionTrigger>
</button>
);
})}
</div>
</ScrollArea>
</div>
<div className="flex-1 flex flex-col bg-background">
<ScrollArea className="flex-1">
<div className="p-3">
{activeIsLoading ? (
<div className="p-4 space-y-4">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-3 bg-muted animate-pulse rounded w-full" />
))}
</div>
) : activeAccountId ? (
<RichTreeView
multiSelect
items={treeData}
checkboxSelection
expansionTrigger="iconContainer"
selectedItems={localSelectedIds.map(String)}
onSelectedItemsChange={(_, itemIds) => {
setLocalSelectedIds(itemIds.map(id => parseInt(id)).filter(id => !isNaN(id)));
}}
slots={{ item: CustomTreeItem }}
sx={{ width: '100%' }}
/>
) : (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-40">
<TreeDeciduous className="h-12 w-12 mb-2 stroke-[1px]" />
<p className="text-xs">{t('search_mailbox.select_account_tip')}</p>
</div>
)}
</div>
</ScrollArea>
<AccordionContent>
<div className="space-y-0.5">
{boxes.map(mailbox => {
const checked =
selectedMailboxIds.includes(mailbox.id)
return (
<TooltipProvider key={mailbox.id}>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={() =>
toggleMailbox(mailbox.id)
}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
'hover:bg-accent transition-colors',
checked &&
'bg-primary/10 text-primary'
)}
>
<Checkbox
checked={checked}
onCheckedChange={() =>
toggleMailbox(mailbox.id)
}
onClick={e =>
e.stopPropagation()
}
/>
<span className="text-xs truncate">
{mailbox.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<div className="text-sm break-all">
{mailbox.name}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
})}
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
)}
</ScrollArea>
<div className="p-3 border-t bg-muted/10 flex items-center justify-between">
<div className="text-[10px] text-muted-foreground font-medium">
{t('search_mailbox.selected_total')}: <span className="text-foreground">{localSelectedIds.length}</span>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => setLocalOpen(false)} className="h-8 px-3 text-xs">
{t('common.cancel')}
</Button>
<Button size="sm" onClick={handleApply} className="h-8 px-4 text-xs gap-1.5 shadow-sm">
<Check className="h-3.5 w-3.5" />
{t('common.apply')}
</Button>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
</Popover >
);
}
+5 -3
View File
@@ -1,12 +1,13 @@
import { type Table } from '@tanstack/react-table'
import { DataTableViewOptions } from './view-options'
import { TagFilterPopover } from '../tag-filter-popover'
import { AccountMailboxFilter } from '../account-mailbox-filter'
import { TimePopover } from '../time-popover'
import { MailFilterPopover } from '../contact-popover'
import { TextSearchInput } from '../text-search-input'
import { MoreFiltersPopover } from '../more-filters-popover'
import { FilterResetButton } from '../filter-reset'
import { MailboxPopover } from '../mailbox-popover'
import { AccountPopover } from '../account-popover'
type DataTableToolbarProps<TData> = {
table: Table<TData>
@@ -25,9 +26,10 @@ export function DataTableToolbar<TData>({
</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">
<TagFilterPopover />
<AccountMailboxFilter />
<AccountPopover />
<MailboxPopover />
<MailFilterPopover />
<TagFilterPopover />
<TimePopover />
<MoreFiltersPopover />
<FilterResetButton />