mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 '.';
|
||||
}
|
||||
@@ -255,7 +255,12 @@
|
||||
"folderSync": {
|
||||
"noData": "لا توجد بيانات",
|
||||
"noFolders": "لا توجد مجلدات للمزامنة",
|
||||
"batches": "دفعات"
|
||||
"batches": "دفعات",
|
||||
"autoSelectDescendants": "تحديد العناصر الفرعية تلقائيًا",
|
||||
"autoSelectParents": "تحديد العناصر الأصلية تلقائيًا",
|
||||
"expandAll": "توسيع الكل",
|
||||
"collapseAll": "طي الكل",
|
||||
"loadingMailboxFolders": "جارٍ تحميل مجلدات البريد…"
|
||||
},
|
||||
"viewDetails": "عرض التفاصيل",
|
||||
"runningState": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -255,7 +255,12 @@
|
||||
"folderSync": {
|
||||
"noData": "データなし",
|
||||
"noFolders": "同期するフォルダーがありません",
|
||||
"batches": "バッチ"
|
||||
"batches": "バッチ",
|
||||
"autoSelectDescendants": "子項目を自動選択",
|
||||
"autoSelectParents": "親項目を自動選択",
|
||||
"expandAll": "すべて展開",
|
||||
"collapseAll": "すべて折りたたむ",
|
||||
"loadingMailboxFolders": "メールボックスフォルダーを読み込み中…"
|
||||
},
|
||||
"viewDetails": "詳細を見る",
|
||||
"runningState": {
|
||||
|
||||
@@ -255,7 +255,12 @@
|
||||
"folderSync": {
|
||||
"noData": "데이터 없음",
|
||||
"noFolders": "동기화할 폴더가 없습니다",
|
||||
"batches": "배치"
|
||||
"batches": "배치",
|
||||
"autoSelectDescendants": "하위 항목 자동 선택",
|
||||
"autoSelectParents": "상위 항목 자동 선택",
|
||||
"expandAll": "모두 펼치기",
|
||||
"collapseAll": "모두 접기",
|
||||
"loadingMailboxFolders": "메일 폴더 로딩 중…"
|
||||
},
|
||||
"viewDetails": "세부 정보 보기",
|
||||
"runningState": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -255,7 +255,12 @@
|
||||
"folderSync": {
|
||||
"noData": "Нет данных",
|
||||
"noFolders": "Нет папок для синхронизации",
|
||||
"batches": "пакетов"
|
||||
"batches": "пакетов",
|
||||
"autoSelectDescendants": "Автоматически выбирать дочерние элементы",
|
||||
"autoSelectParents": "Автоматически выбирать родительские элементы",
|
||||
"expandAll": "Развернуть все",
|
||||
"collapseAll": "Свернуть все",
|
||||
"loadingMailboxFolders": "Загрузка папок почтового ящика…"
|
||||
},
|
||||
"viewDetails": "смотреть детали",
|
||||
"runningState": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -255,7 +255,12 @@
|
||||
"folderSync": {
|
||||
"noData": "無資料",
|
||||
"noFolders": "沒有可同步的資料夾",
|
||||
"batches": "批次"
|
||||
"batches": "批次",
|
||||
"autoSelectDescendants": "自動選取子項",
|
||||
"autoSelectParents": "自動選取父項",
|
||||
"expandAll": "全部展開",
|
||||
"collapseAll": "全部收合",
|
||||
"loadingMailboxFolders": "正在載入郵件資料夾…"
|
||||
},
|
||||
"viewDetails": "檢視詳細資訊",
|
||||
"runningState": {
|
||||
|
||||
@@ -255,7 +255,12 @@
|
||||
"folderSync": {
|
||||
"noData": "无数据",
|
||||
"noFolders": "没有需要同步的文件夹",
|
||||
"batches": "批次"
|
||||
"batches": "批次",
|
||||
"autoSelectDescendants": "自动选择子项",
|
||||
"autoSelectParents": "自动选择父项",
|
||||
"expandAll": "全部展开",
|
||||
"collapseAll": "全部收起",
|
||||
"loadingMailboxFolders": "正在加载邮箱文件夹…"
|
||||
},
|
||||
"viewDetails": "查看详情",
|
||||
"runningState": {
|
||||
|
||||
Reference in New Issue
Block a user