fix(ui): fix sync folder selection jump issue; add auto-select children/parents and expand/collapse all folders button #21

This commit is contained in:
rustmailer
2025-11-30 05:51:17 +08:00
parent 1cfc12324f
commit 82397ab0cd
26 changed files with 1237 additions and 781 deletions
+2 -2
View File
@@ -82,7 +82,7 @@ export function EnvelopeListPagination({
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((size) => (
{[10, 20, 30, 40, 50, 100].map((size) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
@@ -91,7 +91,7 @@ export function EnvelopeListPagination({
</Select>
</div>
<div className='flex items-center justify-center text-sm font-medium'>
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
</div>
<div className='flex items-center space-x-2'>
<Button
-529
View File
@@ -1,529 +0,0 @@
//
// Copyright (c) 2025 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 React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronRight } from 'lucide-react'
import { cva } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const treeVariants = cva(
'group hover:before:opacity-100 before:absolute before:rounded-lg before:left-0 px-2 before:w-full before:opacity-0 before:bg-accent/70 before:h-[2rem] before:-z-10'
)
const selectedTreeVariants = cva(
'before:opacity-100 before:bg-accent/70 text-accent-foreground'
)
interface TreeDataItem {
id: string
name: string
icon?: React.ComponentType<{ className?: string }>
openIcon?: React.ComponentType<{ className?: string }>
children?: TreeDataItem[]
badge?: React.ReactNode,
attributes?: React.ReactNode,
onClick?: () => void
}
type TreeProps = React.HTMLAttributes<HTMLDivElement> & {
data: TreeDataItem[] | TreeDataItem
onSelectChange?: (item: TreeDataItem | undefined) => void
onSelectItemsChange?: (items: TreeDataItem[]) => void
expandAll?: boolean
multiple?: boolean
clickRowToSelect?: boolean
initialSelectedItemIds?: string[]
defaultNodeIcon?: React.ComponentType<{ className?: string }>
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeView = React.forwardRef<HTMLDivElement, TreeProps>(
(
{
data,
onSelectChange,
onSelectItemsChange,
expandAll,
defaultLeafIcon,
defaultNodeIcon,
clickRowToSelect = true,
className,
multiple,
initialSelectedItemIds = [],
...props
},
ref
) => {
const [selectedItemIds, setSelectedItemIds] = React.useState<Set<string>>(
new Set(initialSelectedItemIds)
);
const callbacksRef = React.useRef({
onSelectChange,
onSelectItemsChange
});
React.useEffect(() => {
callbacksRef.current = {
onSelectChange,
onSelectItemsChange
};
}, [onSelectChange, onSelectItemsChange]);
const handleSelectChange = React.useCallback(
(item: TreeDataItem | undefined) => {
if (!item) return;
setSelectedItemIds(prev => {
const newSet = new Set(prev);
if (newSet.has(item.id)) {
newSet.delete(item.id);
} else {
if (!multiple) {
newSet.clear();
}
newSet.add(item.id);
}
setTimeout(() => {
if (callbacksRef.current.onSelectChange) {
callbacksRef.current.onSelectChange(newSet.has(item.id) ? item : undefined);
}
if (callbacksRef.current.onSelectItemsChange) {
const selectedItems = Array.from(newSet)
.map(id => findItemById(data, id))
.filter(Boolean) as TreeDataItem[];
callbacksRef.current.onSelectItemsChange(selectedItems);
}
}, 0);
return newSet;
});
},
[multiple, onSelectChange, onSelectItemsChange, data]
);
const expandedItemIds = React.useMemo(() => {
if (!initialSelectedItemIds || initialSelectedItemIds.length === 0) {
return [] as string[]
}
const ids: string[] = []
function walkTreeItems(
items: TreeDataItem[] | TreeDataItem,
targetIds: string[]
) {
if (Array.isArray(items)) {
for (let i = 0; i < items.length; i++) {
ids.push(items[i]!.id)
if (walkTreeItems(items[i]!, targetIds) && !expandAll) {
return true
}
if (!expandAll) ids.pop()
}
} else if (!expandAll && targetIds.includes(items.id)) {
return true
} else if (items.children) {
return walkTreeItems(items.children, targetIds)
}
}
walkTreeItems(data, initialSelectedItemIds)
return ids
}, [data, expandAll, initialSelectedItemIds])
return (
<div className={cn('overflow-hidden relative p-2', className)}>
<TreeItem
data={data}
ref={ref}
clickRowToSelect={clickRowToSelect}
selectedItemIds={selectedItemIds}
handleSelectChange={handleSelectChange}
expandedItemIds={expandedItemIds}
defaultLeafIcon={defaultLeafIcon}
defaultNodeIcon={defaultNodeIcon}
{...props}
/>
</div>
)
}
)
TreeView.displayName = 'TreeView'
// Helper function to find item by ID in tree
function findItemById(items: TreeDataItem[] | TreeDataItem, id: string): TreeDataItem | undefined {
if (Array.isArray(items)) {
for (const item of items) {
const found = findItemById(item, id);
if (found) return found;
}
} else {
if (items.id === id) return items;
if (items.children) {
return findItemById(items.children, id);
}
}
return undefined;
}
type TreeItemProps = TreeProps & {
selectedItemIds: Set<string>
handleSelectChange: (item: TreeDataItem | undefined) => void
expandedItemIds: string[]
clickRowToSelect?: boolean
defaultNodeIcon?: React.ComponentType<{ className?: string }>
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
(
{
className,
data,
selectedItemIds,
handleSelectChange,
clickRowToSelect,
expandedItemIds,
defaultNodeIcon,
defaultLeafIcon,
...props
},
ref
) => {
if (!Array.isArray(data)) {
data = [data]
}
return (
<div ref={ref} role="tree" className={className} {...props}>
<ul>
{data.map((item) => (
<li key={item.id}>
{item.children ? (
<TreeNode
item={item}
selectedItemIds={selectedItemIds}
expandedItemIds={expandedItemIds}
clickRowToSelect={clickRowToSelect}
handleSelectChange={handleSelectChange}
defaultNodeIcon={defaultNodeIcon}
defaultLeafIcon={defaultLeafIcon}
/>
) : (
<TreeLeaf
item={item}
clickRowToSelect={clickRowToSelect}
selectedItemIds={selectedItemIds}
handleSelectChange={handleSelectChange}
defaultLeafIcon={defaultLeafIcon}
/>
)}
</li>
))}
</ul>
</div>
)
}
)
TreeItem.displayName = 'TreeItem'
interface TreeNodeProps {
item: TreeDataItem
handleSelectChange: (item: TreeDataItem | undefined) => void
expandedItemIds: string[]
clickRowToSelect?: boolean
selectedItemIds: Set<string>
defaultNodeIcon?: React.ComponentType<{ className?: string }>
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeNode = ({
item,
handleSelectChange,
expandedItemIds,
clickRowToSelect,
selectedItemIds,
defaultNodeIcon,
defaultLeafIcon
}: TreeNodeProps) => {
const [value, setValue] = React.useState(
expandedItemIds.includes(item.id) ? [item.id] : []
)
const isSelected = selectedItemIds.has(item.id);
return (
<AccordionPrimitive.Root
type="multiple"
value={value}
onValueChange={(s) => setValue(s)}
>
<AccordionPrimitive.Item value={item.id}>
<AccordionTrigger
className={cn(
"flex items-center w-full py-2",
treeVariants(),
isSelected && selectedTreeVariants()
)}
onClick={(e) => {
e.stopPropagation();
if (clickRowToSelect) {
handleSelectChange(item);
}
item.onClick?.();
}}
>
<div className="flex items-center min-w-0 flex-shrink-0">
<TreeIcon
item={item}
isSelected={isSelected}
isOpen={value.includes(item.id)}
default={defaultNodeIcon}
onCheck={() => { handleSelectChange(item) }}
/>
<span className="ml-2 text-sm truncate">
{item.name}
</span>
</div>
{item.attributes && (
<span className="mx-auto text-sm text-muted-foreground whitespace-nowrap">
{item.attributes}
</span>
)}
{item.badge && (
<TreeBadge isSelected={isSelected}>
{item.badge}
</TreeBadge>
)}
</AccordionTrigger>
<AccordionContent className="ml-4 pl-1 border-l">
<TreeItem
data={item.children ? item.children : item}
selectedItemIds={selectedItemIds}
clickRowToSelect={clickRowToSelect}
handleSelectChange={handleSelectChange}
expandedItemIds={expandedItemIds}
defaultLeafIcon={defaultLeafIcon}
defaultNodeIcon={defaultNodeIcon}
/>
</AccordionContent>
</AccordionPrimitive.Item>
</AccordionPrimitive.Root>
)
}
// function hasSelectedChildrenRecursive(item: TreeDataItem, selectedItemIds: Set<string>): boolean {
// if (!item.children) return false;
// return item.children.some(child =>
// selectedItemIds.has(child.id) ||
// (child.children && hasSelectedChildrenRecursive(child, selectedItemIds)));
// }
interface TreeLeafProps extends React.HTMLAttributes<HTMLDivElement> {
item: TreeDataItem
selectedItemIds: Set<string>
clickRowToSelect?: boolean
handleSelectChange: (item: TreeDataItem | undefined) => void
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeLeaf = React.forwardRef<HTMLDivElement, TreeLeafProps>(
(
{
className,
item,
clickRowToSelect,
selectedItemIds,
handleSelectChange,
defaultLeafIcon,
...props
},
ref
) => {
return (
<div
ref={ref}
className={cn(
"ml-5 flex items-center py-2 cursor-pointer before:right-1",
treeVariants(),
className,
selectedItemIds.has(item.id) && selectedTreeVariants()
)}
onClick={(e) => {
e.stopPropagation();
if (clickRowToSelect) {
handleSelectChange(item);
}
item.onClick?.();
}}
{...props}
>
<div className="flex items-center min-w-0 flex-shrink-0">
<TreeIcon
item={item}
isSelected={selectedItemIds.has(item.id)}
default={defaultLeafIcon}
onCheck={() => { handleSelectChange(item) }}
/>
<span className="ml-2 text-sm truncate">
{item.name}
</span>
</div>
{item.attributes && (
<span className="mx-auto text-sm text-muted-foreground whitespace-nowrap">
{item.attributes}
</span>
)}
{item.badge && (
<TreeBadge isSelected={selectedItemIds.has(item.id)}>
{item.badge}
</TreeBadge>
)}
</div>
)
}
)
TreeLeaf.displayName = 'TreeLeaf'
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header>
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 w-full items-center py-2 transition-all first:[&[data-state=open]>svg]:rotate-90',
className
)}
{...props}
onClick={(e) => {
e.stopPropagation()
if (props.onClick) {
props.onClick(e)
}
}}
>
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200 text-accent-foreground/50 mr-1" />
{children}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
'overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
className
)}
{...props}
>
<div className="pb-1 pt-0">{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
interface TreeIconProps {
item: TreeDataItem;
isOpen?: boolean;
isSelected?: boolean;
default?: React.ComponentType<{ className?: string }>;
onCheck?: (checked: boolean) => void;
}
const TreeIcon = ({
item,
isOpen,
isSelected,
default: defaultIcon,
onCheck,
}: TreeIconProps) => {
let Icon = defaultIcon;
if (isOpen && item.openIcon) {
Icon = item.openIcon;
} else if (item.icon) {
Icon = item.icon;
}
return (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => onCheck?.(!isSelected)}
onClick={(e) => e.stopPropagation()}
className={cn(
"h-4 w-4 rounded border border-primary dark:border-white shadow transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isSelected
? "bg-black dark:bg-white text-primary-foreground"
: "bg-transparent",
"appearance-none cursor-pointer flex items-center justify-center relative",
"after:content-[''] after:w-1.5 after:h-2",
"after:border-r-2 after:border-b-2 after:rotate-45 after:mt-[-2px]",
isSelected
? "after:block after:border-white dark:after:border-black after:z-10"
: "after:hidden"
)}
/>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
</div>
);
};
interface TreeBadgeProps {
children: React.ReactNode
isSelected: boolean
showOnSelectedOnly?: boolean
}
const TreeBadge = ({
children,
isSelected,
showOnSelectedOnly = false
}: TreeBadgeProps) => {
return (
<div
className={cn(
showOnSelectedOnly
? isSelected
? 'block'
: 'hidden'
: 'block',
'absolute right-3 group-hover:block'
)}
>
{children}
</div>
)
}
export { TreeView, type TreeDataItem }
@@ -32,14 +32,113 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { AccountModel } from '../data/schema'
import { toast } from '@/hooks/use-toast'
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
import { buildTree } from '@/lib/build-tree'
import { TreeDataItem, TreeView } from '@/components/tree-view'
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree'
import { Skeleton } from '@/components/ui/skeleton'
import { update_account } from '@/api/account/api'
import { ToastAction } from '@/components/ui/toast'
import axios, { AxiosError } from 'axios'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useTranslation } from 'react-i18next'
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
import { useTheme } from '@/context/theme-context'
import React from 'react'
import Collapse from '@mui/material/Collapse';
import { styled } from '@mui/material/styles';
import { TreeItemCheckbox, TreeItemContent, TreeItemIconContainer, TreeItemLabel, TreeItemRoot } from '@mui/x-tree-view/TreeItem'
import { TreeItemDragAndDropOverlay, TreeItemIcon, TreeItemProvider, TreeViewBaseItem, TreeViewSelectionPropagation, useTreeItem, useTreeItemModel, UseTreeItemParameters } from '@mui/x-tree-view'
import { animated, useSpring } from '@react-spring/web';
import { TransitionProps } from '@mui/material/transitions'
function getParentIds(tree: TreeViewBaseItem[]): string[] {
const result: string[] = [];
function traverse(nodes: TreeViewBaseItem[]) {
for (const node of nodes) {
if (node.children && node.children.length > 0) {
result.push(node.id);
traverse(node.children);
}
}
}
traverse(tree);
return result;
}
interface CustomLabelProps {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode;
icon?: React.ElementType;
expandable?: boolean;
}
function CustomLabel({
expandable,
exists,
attributes,
children,
...other
}: CustomLabelProps) {
return (
<TreeItemLabel
{...other}
sx={{
display: 'flex',
alignItems: 'center',
}}
>
<span className="font-medium text-sm text-inherit">
{children}
</span>
<div className="flex gap-2 ml-auto mr-3 opacity-70 text-xs">
{attributes?.map((attr) => {
const text =
attr.attr === 'Extension'
? attr.extension
: attr.attr;
return (
<span key={attr.attr} className="text-inherit">
{text}
</span>
);
})}
</div>
{exists !== undefined && (
<span
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
>
{exists}
</span>
)}
</TreeItemLabel>
);
}
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 Props {
open: boolean
@@ -48,15 +147,24 @@ interface Props {
}
export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
const [selectedFolders, setSelectedFolders] = useState<string[]>(currentRow.sync_folders || []);
const [selectedItems, setSelectedItems] = React.useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [allIds, setAllIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const [itemsWithChildren, setItemsWithChildren] = useState<string[]>([]);
const [mailboxes, setMailboxes] = useState<MailboxData[]>([]);
const [selectionPropagation, setSelectionPropagation] =
React.useState<TreeViewSelectionPropagation>({
parents: false,
descendants: true,
});
const [treeData, setTreeData] = useState<TreeViewBaseItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const queryClient = useQueryClient();
const { t } = useTranslation()
const { theme } = useTheme()
useEffect(() => {
if (!open) return;
@@ -67,6 +175,18 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
const data = await list_mailboxes(currentRow.id, true);
if (!cancelled) {
setMailboxes(data);
const allIds = data.map(mailbox => String(mailbox.id));
setAllIds(allIds);
const tree = buildTree(data);
setTreeData(tree);
const itemsWithChildren = getParentIds(tree);
setItemsWithChildren(itemsWithChildren);
setExpandedItems(itemsWithChildren);
const sync_folders = data
.filter(mailbox => currentRow.sync_folders.includes(mailbox.name))
.map(mailbox => mailbox.id.toString());
setSelectedItems(sync_folders);
setError(undefined);
}
} catch (err: any) {
@@ -77,11 +197,6 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
} else {
setError(err.message);
}
} else {
console.error('Other error:', err);
}
if (!cancelled) {
setMailboxes([]);
}
} finally {
if (!cancelled) setIsLoading(false);
@@ -93,61 +208,90 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
};
}, [currentRow, open]);
// Convert mailbox names to IDs for initial selection
const initialSelectedItemIds = useMemo(() => {
if (!mailboxes) return [];
return mailboxes
.filter(mailbox => selectedFolders.includes(mailbox.name))
.map(mailbox => mailbox.id.toString());
}, [mailboxes, selectedFolders]);
const treeData = useMemo(() => {
if (!mailboxes) return [];
return buildTree(mailboxes, undefined, true, true);
}, [mailboxes]);
const handleSelectItems = useCallback((selectedItems: TreeDataItem[]) => {
const allMailboxes = mailboxes || [];
const selected = selectedItems
.map(item => mailboxes?.find(m => m.id === parseInt(item.id, 10))?.name)
.filter(Boolean) as string[];
const handleExpandedItemsChange = (
_event: React.SyntheticEvent | null,
itemIds: string[],
) => {
setExpandedItems(itemIds);
};
const allMailSelected = selected.some(selectedName => {
const mailbox = allMailboxes.find(m => m.name === selectedName);
if (!mailbox) return false;
return mailbox.attributes.some(a => a.attr === 'All');
const handleExpandClick = () => {
setExpandedItems((oldExpanded) =>
oldExpanded.length === 0 ? itemsWithChildren : [],
);
};
const CustomTreeItem = useMemo(() => {
return React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps,
ref: React.Ref<HTMLLIElement>,
) {
const { id, itemId, label, disabled, children, ...other } = props;
const {
getContextProviderProps,
getRootProps,
getContentProps,
getIconContainerProps,
getCheckboxProps,
getLabelProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
return (
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)}>
<TreeItemContent {...getContentProps()}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} />
<CustomLabel
{...getLabelProps({
exists: item.exists,
attributes: item.attributes,
expandable: status.expandable && status.expanded,
})}
/>
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
</TreeItemContent>
{children && <TransitionComponent {...getGroupTransitionProps()} />}
</TreeItemRoot>
</TreeItemProvider>
);
});
}, [theme]);
if (allMailSelected) {
const handleSelectAll = useCallback(() => {
const selectedSet = new Set(allIds);
const selectedAllMailbox = mailboxes.find((mb) =>
selectedSet.has(String(mb.id)) &&
mb.attributes?.some(a => a.attr === 'All')
);
if (selectedAllMailbox) {
toast({
title: t('accounts.allMailFolderSelected'),
description: t('accounts.allMailFolderSelectedDesc'),
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
});
}
setSelectedFolders(selected);
}, [mailboxes]);
const handleSelectAll = useCallback(() => {
if (!mailboxes) return;
const validFolderNames = mailboxes
.filter(mailbox => {
const isAllMail = mailbox.attributes.some(a => a.attr === 'All');
if (isAllMail) return false;
return true;
})
.map(m => m.name);
setSelectedFolders(validFolderNames);
if (validFolderNames.length < mailboxes.length) {
toast({
description: t('accounts.allMailSkipped'),
});
}
}, [mailboxes]);
setSelectedItems(allIds);
}, [allIds]);
const handleDeselectAll = useCallback(() => {
setSelectedFolders([]);
setSelectedItems([]);
}, []);
@@ -184,8 +328,30 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
console.error(error);
}
const handleSelectedItemsChange = (
_event: React.SyntheticEvent | null,
newSelectedItems: string[],
) => {
const selectedSet = new Set(newSelectedItems);
const selectedAllMailbox = mailboxes.find((mb) =>
selectedSet.has(String(mb.id)) &&
mb.attributes?.some(a => a.attr === 'All')
);
if (selectedAllMailbox) {
toast({
title: t('accounts.allMailFolderSelected'),
description: t('accounts.allMailFolderSelectedDesc'),
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
});
}
setSelectedItems(newSelectedItems);
};
const handleSubmit = async () => {
if (selectedFolders.length === 0) {
if (selectedItems.length === 0) {
toast({
title: t('common.error'),
description: t('accounts.selectAtLeastOneFolder'),
@@ -194,14 +360,23 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
return;
}
setIsSubmitting(true);
const selectedNames: string[] = [];
const idSet = new Set(selectedItems);
for (const mailbox of mailboxes) {
if (idSet.has(String(mailbox.id))) {
selectedNames.push(mailbox.name);
}
}
updateMutation.mutate({
sync_folders: selectedFolders,
sync_folders: selectedNames,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t('accounts.selectSyncFolders')}</DialogTitle>
<DialogDescription>
@@ -210,13 +385,13 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between pt-2">
<div className="flex gap-2">
<div className="flex flex-col pt-2 gap-2">
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
disabled={isLoading || !mailboxes || mailboxes.length === 0}
disabled={isLoading || !allIds || allIds.length === 0}
className="h-8"
>
<CheckSquare className="w-4 h-4 mr-2" />
@@ -226,23 +401,62 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
variant="outline"
size="sm"
onClick={handleDeselectAll}
disabled={isLoading || selectedFolders.length === 0}
disabled={isLoading || allIds.length === 0}
className="h-8"
>
<Square className="w-4 h-4 mr-2" />
{t('common.deselectAll')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setSelectionPropagation(prev => ({
...prev,
descendants: !prev.descendants,
}))
}
disabled={isLoading}
className="h-8"
>
{selectionPropagation.descendants ? <CheckSquare className="w-4 h-4 mr-2" /> : <Square className="w-4 h-4 mr-2" />}
{t('accounts.folderSync.autoSelectDescendants')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setSelectionPropagation(prev => ({
...prev,
parents: !prev.parents,
}))
}
disabled={isLoading}
className="h-8"
>
{selectionPropagation.parents ? <CheckSquare className="w-4 h-4 mr-2" /> : <Square className="w-4 h-4 mr-2" />}
{t('accounts.folderSync.autoSelectParents')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExpandClick}
disabled={isLoading}>
{expandedItems.length === 0 ? t('accounts.folderSync.expandAll') : t('accounts.folderSync.collapseAll')}
</Button>
</div>
<div className="text-sm text-muted-foreground">
{t('accounts.foldersSelected', { count: selectedFolders.length })}
{t('accounts.foldersSelected', { count: selectedItems.length })}
</div>
</div>
<ScrollArea className="h-[30rem] w-full pr-4 -mr-4 py-1">
<ScrollArea className="h-[35rem] w-full pr-4 -mr-4 py-1">
{isLoading && (
<div className="p-8 space-y-8">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm font-medium">Loading mailbox folders</span>
<span className="text-sm font-medium">{t('accounts.folderSync.loadingMailboxFolders')}</span>
</div>
<div className="space-y-2">
@@ -253,14 +467,16 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
</div>
)}
{!isLoading && (
<TreeView
key={selectedFolders.length > 0 ? `tree-${selectedFolders.length}-${selectedFolders[0]}` : 'tree-empty'}
data={treeData}
multiple
expandAll
clickRowToSelect={false}
initialSelectedItemIds={initialSelectedItemIds}
onSelectItemsChange={handleSelectItems}
<RichTreeView
multiSelect
checkboxSelection
items={treeData}
expandedItems={expandedItems}
onExpandedItemsChange={handleExpandedItemsChange}
selectionPropagation={selectionPropagation}
selectedItems={selectedItems}
onSelectedItemsChange={handleSelectedItemsChange}
slots={{ item: CustomTreeItem }}
/>
)}
{error && (
@@ -60,7 +60,6 @@ export function MailList({
}
}
const hasSelected = (mailId: number) => {
return selected.has(mailId);
}
@@ -77,15 +76,14 @@ export function MailList({
});
}
if (isLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 flex-1 max-w-xs" />
<Skeleton className="h-3 w-16 ml-auto" />
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1 max-w-xs" />
<Skeleton className="h-2.5 w-12 ml-auto" />
</div>
))}
</div>
@@ -95,7 +93,7 @@ export function MailList({
return (
<div className="divide-y divide-border">
{items.length > 0 && (
<div className="flex items-center gap-3 px-3 py-2 bg-muted/30">
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
<Checkbox
checked={
selected.size === items.length && items.length > 0
@@ -123,7 +121,7 @@ export function MailList({
<div
key={index}
className={cn(
"group flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
"group flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
"hover:bg-accent/50",
isSelected && "bg-accent"
)}
@@ -140,12 +138,11 @@ export function MailList({
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0"
/>
<MailIcon className="h-4 w-4 text-muted-foreground shrink-0" />
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
{/* LEFT AREA: From + Subject + Tags */}
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-1">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
{item.subject}
@@ -156,7 +153,7 @@ export function MailList({
</h3>
{/* TAGS BELOW SUBJECT */}
<div className="flex flex-wrap gap-1 mt-0.5">
<div className="flex flex-wrap gap-1 mt-0.25">
{item.tags?.map((tag, i) => (
<Badge
key={i}
@@ -169,10 +166,10 @@ export function MailList({
</div>
{/* RIGHT AREA actions & meta */}
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-2 text-xs text-muted-foreground">
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (
<div className="flex items-center gap-1">
<Paperclip className="h-3.5 w-3.5" />
<Paperclip className="h-3 w-3" />
<span>{item.attachments?.length}</span>
</div>
)}
@@ -190,7 +187,7 @@ export function MailList({
}}
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-all"
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
@@ -200,4 +197,4 @@ export function MailList({
{totalSelected > 0 && <MailBulkActions />}
</div>
)
}
}
+165 -13
View File
@@ -27,11 +27,9 @@ import {
import { Separator } from "@/components/ui/separator"
import { TooltipProvider } from "@/components/ui/tooltip"
import { AccountSwitcher } from "./account-switcher"
import { TreeView } from "@/components/tree-view"
import { ScrollArea } from "@/components/ui/scroll-area"
import { list_mailboxes, MailboxData } from "@/api/mailbox/api"
import { useQuery } from "@tanstack/react-query"
import { buildTree } from "../../../lib/build-tree"
import { Skeleton } from "@/components/ui/skeleton"
import MailboxProvider, { MailboxDialogType } from "../context"
import useDialogState from "@/hooks/use-dialog-state"
@@ -44,6 +42,14 @@ import { EnvelopeDeleteDialog } from "./delete-dialog"
import Logo from '@/assets/logo.svg'
import { EmailEnvelope } from "@/api"
import { EnvelopeListPagination } from "@/components/pagination"
import { RichTreeView, TreeItemCheckbox, TreeItemContent, TreeItemDragAndDropOverlay, TreeItemIcon, TreeItemIconContainer, TreeItemLabel, TreeItemProvider, TreeItemRoot, useTreeItem, useTreeItemModel, UseTreeItemParameters } from "@mui/x-tree-view"
import { buildTree, ExtendedTreeItemProps } from "@/lib/build-tree"
import { useTheme } from "@/context/theme-context"
import { styled } from "@mui/material/styles"
import { animated, useSpring } from "@react-spring/web"
import { TransitionProps } from "@mui/material/transitions"
import Collapse from "@mui/material/Collapse"
import { FolderIcon } from "lucide-react"
interface MailProps {
@@ -73,6 +79,83 @@ const useListMessages = ({ accountId, mailboxId, page, page_size }: ListMessages
};
interface CustomLabelProps {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode;
icon?: React.ElementType;
expandable?: boolean;
}
function CustomLabel({
expandable,
exists,
attributes,
children,
...other
}: CustomLabelProps) {
return (
<TreeItemLabel
{...other}
sx={{
display: 'flex',
alignItems: 'center',
}}
>
<FolderIcon className="mr-2"/>
<span className="font-medium text-sm text-inherit">
{children}
</span>
{/* <div className="flex gap-2 ml-auto mr-3 opacity-70 text-xs">
{attributes?.map((attr) => {
const text =
attr.attr === 'Extension'
? attr.extension
: attr.attr;
return (
<span key={attr.attr} className="text-inherit">
{text}
</span>
);
})}
</div>
{exists !== undefined && (
<span
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
>
{exists}
</span>
)} */}
</TreeItemLabel>
);
}
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'> { }
export function Mail({
defaultLayout = [20, 80],
defaultCollapsed = false,
@@ -88,6 +171,7 @@ export function Mail({
const [pageSize, setPageSize] = React.useState(30);
const [deleteIds, setDeleteIds] = React.useState<Set<number>>(() => new Set());
const [selected, setSelected] = React.useState<Set<number>>(() => new Set());
const { theme } = useTheme()
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
queryKey: ['account-mailboxes', `${selectedAccountId}`],
@@ -95,6 +179,9 @@ export function Mail({
enabled: !!selectedAccountId,
})
const tree = buildTree(mailboxes ?? []);
const { data: envelopes, isLoading: isMessagesLoading, isError, error } = useListMessages({
accountId: selectedAccountId,
mailboxId: selectedMailbox?.id,
@@ -126,6 +213,65 @@ export function Mail({
}
}, [isError, error]);
const handleItemSelectionToggle = (
_event: React.SyntheticEvent | null,
itemId: string,
isSelected: boolean,
) => {
if (isSelected) {
setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
setPage(0);
}
};
const CustomTreeItem = React.useMemo(() => {
return React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps,
ref: React.Ref<HTMLLIElement>,
) {
const { id, itemId, label, disabled, children, ...other } = props;
const {
getContextProviderProps,
getRootProps,
getContentProps,
getIconContainerProps,
getCheckboxProps,
getLabelProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
return (
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)}>
<TreeItemContent {...getContentProps()}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} />
<CustomLabel
{...getLabelProps({
exists: item.exists,
attributes: item.attributes,
expandable: status.expandable && status.expanded,
})}
/>
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
</TreeItemContent>
{children && <TransitionComponent {...getGroupTransitionProps()} />}
</TreeItemRoot>
</TreeItemProvider>
);
});
}, [theme]);
return (
<MailboxProvider value={{ open, setOpen, currentMailbox: selectedMailbox, selectedAccountId, setCurrentMailbox: setSelectedMailbox, currentEnvelope: selectedEvelope, setCurrentEnvelope: setSelectedEvelope, deleteIds, setDeleteIds, selected, setSelected }}>
<TooltipProvider delayDuration={0}>
@@ -184,17 +330,23 @@ export function Mail({
))}
</div>
) : (
<TreeView
data={buildTree(mailboxes ?? [])}
clickRowToSelect={true}
onSelectChange={(item) => {
if (item) {
setSelectedMailbox(mailboxes?.find(m => m.id === parseInt(item.id, 10)))
setPage(0);
} else {
setSelectedMailbox(undefined)
}
}}
// <TreeView
// data={buildTree(mailboxes ?? [])}
// clickRowToSelect={true}
// onSelectChange={(item) => {
// if (item) {
// setSelectedMailbox(mailboxes?.find(m => m.id === parseInt(item.id, 10)))
// setPage(0);
// } else {
// setSelectedMailbox(undefined)
// }
// }}
// />
<RichTreeView
//checkboxSelection
items={tree}
onItemSelectionToggle={handleItemSelectionToggle}
slots={{ item: CustomTreeItem }}
/>
)}
</ScrollArea>
+24 -31
View File
@@ -21,7 +21,7 @@ import { cn, formatBytes } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Checkbox } from "@/components/ui/checkbox" // shadcn Checkbox
import { Checkbox } from "@/components/ui/checkbox"
import { EmailEnvelope } from "@/api"
import { useSearchContext } from "./context"
import { MailBulkActions } from "./bulk-actions"
@@ -62,6 +62,7 @@ export function MailList({
});
}
}
const toggleToDelete = (accountId: number, mailId: number) => {
setToDelete(prev => {
const next = new Map(prev);
@@ -115,11 +116,11 @@ export function MailList({
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-3">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-3 w-20" />
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-3 w-3" />
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-2.5 w-16" />
</div>
))}
</div>
@@ -129,7 +130,7 @@ export function MailList({
return (
<div className="divide-y divide-border">
{items.length > 0 && (
<div className="flex items-center gap-3 px-3 py-2 bg-muted/30">
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
<Checkbox
checked={
totalSelected === items.length && items.length > 0
@@ -158,7 +159,7 @@ export function MailList({
<div
key={index}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
"flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
"hover:bg-accent/50",
isSelectedRow && "bg-accent"
)}
@@ -175,41 +176,35 @@ export function MailList({
className="h-4 w-4 shrink-0"
/>
<MailIcon className="h-4 w-4 text-muted-foreground shrink-0" />
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
{/* LEFT AREA: From + Subject + Tags */}
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0">
{/* from + subject (large screen side by side, small screen subject hidden) */}
<div className="flex items-center gap-2 min-w-0">
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
{/* subject on large screens */}
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
{item.subject}
</h3>
</div>
{/* subject on small screens */}
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
{item.subject}
</h3>
{/* TAGS (always below on small screen, inline on large screen) */}
<div className="flex flex-wrap gap-1 mt-0.5">
{item.tags?.map((tag, index) => (
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={index}>{tag}</Badge>
<div className="flex flex-wrap gap-1 mt-0.25">
{item.tags?.map((tag, i) => (
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={i}>{tag}</Badge>
))}
</div>
</div>
{/* RIGHT AREA actions & meta */}
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-2 text-xs text-muted-foreground">
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (
<div className="flex items-center gap-1">
<Paperclip className="h-3.5 w-3.5" />
<Paperclip className="h-3 w-3" />
<span>{item.attachments?.length}</span>
</div>
)}
@@ -225,14 +220,14 @@ export function MailList({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 p-0 hover:bg-muted rounded-md"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
@@ -241,7 +236,7 @@ export function MailList({
setOpen("edit-tags");
}}
>
<TagIcon className="ml-2 h-4 w-4" />
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('search.editTag')}
</DropdownMenuItem>
@@ -253,7 +248,7 @@ export function MailList({
handleDelete(item);
}}
>
<Trash2 className="ml-2 h-4 w-4" />
<Trash2 className="ml-2 h-3.5 w-3.5" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -263,9 +258,7 @@ export function MailList({
</div>
)
})}
{totalSelected > 0 && (
<MailBulkActions />
)}
{totalSelected > 0 && <MailBulkActions />}
</div>
)
}
}
+78 -88
View File
@@ -18,100 +18,90 @@
import { MailboxData } from "@/api/mailbox/api";
import { TreeDataItem } from "@/components/tree-view";
import { Badge } from '@/components/ui/badge'
import { FolderClosed, FolderOpen } from "lucide-react";
import React from 'react';
import { TreeViewBaseItem } from '@mui/x-tree-view/models';
type BadgeContentFunction = (item: MailboxData) => React.ReactNode;
export const buildTree = (data: MailboxData[], badgeContent?: BadgeContentFunction, showAttributes?: boolean, showExists?: boolean): TreeDataItem[] => {
const root: TreeDataItem = {
id: 'root',
name: 'Root',
icon: FolderClosed,
openIcon: FolderOpen,
children: [], // Ensure children is initialized as an array
};
export type ExtendedTreeItemProps = {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
id: string;
label: string;
};
const nodeMap: { [key: string]: TreeDataItem } = {};
data.sort((a, b) => a.name.localeCompare(b.name));
data.forEach((item) => {
const { id, name, delimiter, exists, attributes } = item;
const badge = showExists ? (badgeContent ? badgeContent(item) : React.createElement(Badge, {
className: 'text-[12px]',
variant: 'secondary',
}, exists)) : null;
const attributesNode = showAttributes
? attributes.map((item, index) =>
React.createElement(
Badge,
{
key: index,
className: 'text-[12px] mr-1 last:mr-0',
variant: 'secondary',
},
item.attr === 'Extension' ? item.extension || '' : item.attr
)
)
: null;
// const badge = badgeContent || React.createElement(Badge, {
// className: 'text-xs',
// }, exists);
// If there is no delimiter, add the item directly as a child of the root
if (!delimiter) {
root.children!.push({
id: id.toString(),
name,
icon: FolderClosed,
openIcon: FolderOpen,
badge,
attributes: showAttributes ? attributesNode : null,
children: undefined, // Leaf node, so children is undefined
});
return;
export function buildTree(items: MailboxData[]): TreeViewBaseItem<ExtendedTreeItemProps>[] {
const nodeByName = new Map<string, TreeViewBaseItem<ExtendedTreeItemProps>>();
for (const mb of items) {
if (!mb.name) continue;
const delimiter = mb.delimiter ?? '/';
const parts = mb.name.split(delimiter);
let currentFullName = '';
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
currentFullName = currentFullName ? `${currentFullName}${delimiter}${part}` : part;
if (!nodeByName.has(currentFullName)) {
nodeByName.set(currentFullName, {
id: currentFullName,
label: part,
exists: mb.exists,
attributes: mb.attributes,
children: [],
});
}
if (i === parts.length - 1) {
const node = nodeByName.get(currentFullName)!;
node.id = String(mb.id);
}
}
}
const roots: TreeViewBaseItem<ExtendedTreeItemProps>[] = [];
for (const [fullName, node] of nodeByName.entries()) {
const delim = mbDelimiterOrDefault(fullName, items);
const lastDelimIndex = fullName.lastIndexOf(delim);
if (lastDelimIndex === -1) {
roots.push(node);
continue;
}
// Split the name into parts based on the delimiter
const parts = name.split(delimiter); // dir/sub1/sub2
let currentParent = root;
const parentFullName = fullName.substring(0, lastDelimIndex);
const parentNode = nodeByName.get(parentFullName);
// Traverse or create nodes for each part of the path
parts.forEach((part, index) => {
const path = parts.slice(0, index + 1).join(delimiter);
// If the node already exists, set it as the current parent
if (nodeMap[path]) {
currentParent = nodeMap[path];
} else {
// Determine if this is a leaf node (last part of the path)
const isLeaf = index === parts.length - 1;
// Create a new node
const newNode: TreeDataItem = {
id: isLeaf ? id.toString() : path, // Use item.id for leaf nodes, path for non-leaf nodes
name: part,
icon: FolderClosed,
openIcon: FolderOpen,
badge,
attributes: showAttributes ? attributesNode : null,
children: isLeaf ? undefined : [], // Ensure children is initialized as an array for non-leaf nodes
};
// Ensure currentParent.children is initialized as an array
if (!currentParent.children) {
currentParent.children = [];
}
// Add the new node to the current parent's children
currentParent.children.push(newNode);
currentParent = newNode; // Update the current parent to the new node
nodeMap[path] = newNode; // Store the node in the map for quick lookup
if (parentNode) {
parentNode.children = parentNode.children ?? [];
if (!parentNode.children.includes(node)) {
parentNode.children.push(node);
}
});
});
} else {
roots.push(node);
}
}
// Return the children of the root as the final tree structure
return root.children!;
};
const sortNodes = (nodes: TreeViewBaseItem[]) => {
nodes.sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }));
for (const n of nodes) {
if (n.children && n.children.length) sortNodes(n.children);
}
};
const uniqueRoots = Array.from(new Set(roots));
sortNodes(uniqueRoots);
return uniqueRoots;
}
function mbDelimiterOrDefault(fullName: string, items: MailboxData[]): string {
const mb = items.find(it => it.name === fullName || fullName.startsWith(it.name + (it.delimiter ?? '/')));
if (mb?.delimiter) return mb.delimiter;
const withDelim = items.find(it => it.delimiter);
if (withDelim?.delimiter) return withDelim.delimiter;
return '.';
}
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "لا توجد بيانات",
"noFolders": "لا توجد مجلدات للمزامنة",
"batches": "دفعات"
"batches": "دفعات",
"autoSelectDescendants": "تحديد العناصر الفرعية تلقائيًا",
"autoSelectParents": "تحديد العناصر الأصلية تلقائيًا",
"expandAll": "توسيع الكل",
"collapseAll": "طي الكل",
"loadingMailboxFolders": "جارٍ تحميل مجلدات البريد…"
},
"viewDetails": "عرض التفاصيل",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Ingen data",
"noFolders": "Ingen mapper at synkronisere",
"batches": "batcher"
"batches": "batcher",
"autoSelectDescendants": "Vælg efterkommere automatisk",
"autoSelectParents": "Vælg forældre automatisk",
"expandAll": "Udvid alle",
"collapseAll": "Skjul alle",
"loadingMailboxFolders": "Indlæser postkassemapper…"
},
"viewDetails": "vis detaljer",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Keine Daten",
"noFolders": "Keine Ordner zum Synchronisieren",
"batches": "Batches"
"batches": "Batches",
"autoSelectDescendants": "Unterelemente automatisch auswählen",
"autoSelectParents": "Elternelemente automatisch auswählen",
"expandAll": "Alle erweitern",
"collapseAll": "Alle reduzieren",
"loadingMailboxFolders": "Postfachordner werden geladen…"
},
"viewDetails": "Details anzeigen",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "No Data",
"noFolders": "No folders to sync",
"batches": "batches"
"batches": "batches",
"autoSelectDescendants": "Auto select descendants",
"autoSelectParents": "Auto select parents",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"loadingMailboxFolders": "Loading mailbox folders…"
},
"viewDetails": "view details",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Sin datos",
"noFolders": "No hay carpetas para sincronizar",
"batches": "lotes"
"batches": "lotes",
"autoSelectDescendants": "Seleccionar automáticamente los descendientes",
"autoSelectParents": "Seleccionar automáticamente los padres",
"expandAll": "Expandir todo",
"collapseAll": "Contraer todo",
"loadingMailboxFolders": "Cargando carpetas del buzón…"
},
"viewDetails": "ver detalles",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Ei tietoja",
"noFolders": "Ei synkronoitavia kansioita",
"batches": "erät"
"batches": "erät",
"autoSelectDescendants": "Valitse alisolmut automaattisesti",
"autoSelectParents": "Valitse yläsolmut automaattisesti",
"expandAll": "Laajenna kaikki",
"collapseAll": "Kutista kaikki",
"loadingMailboxFolders": "Ladataan postilaatikon kansioita…"
},
"viewDetails": "katso tiedot",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Aucune Donnée",
"noFolders": "Aucun dossier à synchroniser",
"batches": "lots"
"batches": "lots",
"autoSelectDescendants": "Sélectionner automatiquement les descendants",
"autoSelectParents": "Sélectionner automatiquement les parents",
"expandAll": "Tout développer",
"collapseAll": "Tout réduire",
"loadingMailboxFolders": "Chargement des dossiers de la boîte…"
},
"viewDetails": "voir les détails",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Nessun Dato",
"noFolders": "Nessuna cartella da sincronizzare",
"batches": "lotti"
"batches": "lotti",
"autoSelectDescendants": "Seleziona automaticamente i discendenti",
"autoSelectParents": "Seleziona automaticamente i genitori",
"expandAll": "Espandi tutto",
"collapseAll": "Comprimi tutto",
"loadingMailboxFolders": "Caricamento delle cartelle della casella…"
},
"viewDetails": "visualizza dettagli",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "データなし",
"noFolders": "同期するフォルダーがありません",
"batches": "バッチ"
"batches": "バッチ",
"autoSelectDescendants": "子項目を自動選択",
"autoSelectParents": "親項目を自動選択",
"expandAll": "すべて展開",
"collapseAll": "すべて折りたたむ",
"loadingMailboxFolders": "メールボックスフォルダーを読み込み中…"
},
"viewDetails": "詳細を見る",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "데이터 없음",
"noFolders": "동기화할 폴더가 없습니다",
"batches": "배치"
"batches": "배치",
"autoSelectDescendants": "하위 항목 자동 선택",
"autoSelectParents": "상위 항목 자동 선택",
"expandAll": "모두 펼치기",
"collapseAll": "모두 접기",
"loadingMailboxFolders": "메일 폴더 로딩 중…"
},
"viewDetails": "세부 정보 보기",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Geen Gegevens",
"noFolders": "Geen mappen om te synchroniseren",
"batches": "batches"
"batches": "batches",
"autoSelectDescendants": "Automatisch onderliggende items selecteren",
"autoSelectParents": "Automatisch bovenliggende items selecteren",
"expandAll": "Alles uitklappen",
"collapseAll": "Alles inklappen",
"loadingMailboxFolders": "Postvakken laden…"
},
"viewDetails": "details bekijken",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Ingen data",
"noFolders": "Ingen mapper å synkronisere",
"batches": "partier"
"batches": "partier",
"autoSelectDescendants": "Velg etterkommere automatisk",
"autoSelectParents": "Velg foreldre automatisk",
"expandAll": "Utvid alle",
"collapseAll": "Skjul alle",
"loadingMailboxFolders": "Laster inn postkassemapper…"
},
"viewDetails": "se detaljer",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Sem Dados",
"noFolders": "Nenhuma pasta para sincronizar",
"batches": "Lotes"
"batches": "Lotes",
"autoSelectDescendants": "Selecionar automaticamente os descendentes",
"autoSelectParents": "Selecionar automaticamente os pais",
"expandAll": "Expandir tudo",
"collapseAll": "Recolher tudo",
"loadingMailboxFolders": "Carregando pastas da caixa de correio…"
},
"viewDetails": "Ver Detalhes",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Нет данных",
"noFolders": "Нет папок для синхронизации",
"batches": "пакетов"
"batches": "пакетов",
"autoSelectDescendants": "Автоматически выбирать дочерние элементы",
"autoSelectParents": "Автоматически выбирать родительские элементы",
"expandAll": "Развернуть все",
"collapseAll": "Свернуть все",
"loadingMailboxFolders": "Загрузка папок почтового ящика…"
},
"viewDetails": "смотреть детали",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "Inga data",
"noFolders": "Inga mappar att synkronisera",
"batches": "satser"
"batches": "satser",
"autoSelectDescendants": "Välj underordnade automatiskt",
"autoSelectParents": "Välj överordnade automatiskt",
"expandAll": "Expandera alla",
"collapseAll": "Komprimera alla",
"loadingMailboxFolders": "Laddar brevlådemappar…"
},
"viewDetails": "visa detaljer",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "無資料",
"noFolders": "沒有可同步的資料夾",
"batches": "批次"
"batches": "批次",
"autoSelectDescendants": "自動選取子項",
"autoSelectParents": "自動選取父項",
"expandAll": "全部展開",
"collapseAll": "全部收合",
"loadingMailboxFolders": "正在載入郵件資料夾…"
},
"viewDetails": "檢視詳細資訊",
"runningState": {
+6 -1
View File
@@ -255,7 +255,12 @@
"folderSync": {
"noData": "无数据",
"noFolders": "没有需要同步的文件夹",
"batches": "批次"
"batches": "批次",
"autoSelectDescendants": "自动选择子项",
"autoSelectParents": "自动选择父项",
"expandAll": "全部展开",
"collapseAll": "全部收起",
"loadingMailboxFolders": "正在加载邮箱文件夹…"
},
"viewDetails": "查看详情",
"runningState": {