mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(search): integrate mailbox directory tree into search interface
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
import { AccountPopover } from './account-popover'
|
||||
import { MailboxPopover } from './mailbox-popover'
|
||||
|
||||
export function AccountMailboxFilter() {
|
||||
return (
|
||||
<>
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 >
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user