mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(search): integrate mailbox directory tree into search interface
This commit is contained in:
@@ -16,6 +16,8 @@
|
||||
// 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/>.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders};
|
||||
|
||||
use crate::{
|
||||
@@ -108,7 +110,7 @@ R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
|
||||
|
||||
#[tokio::test]
|
||||
async fn test44() {
|
||||
let path = r"C:\Users\polly\Downloads\test333.eml";
|
||||
let path = r"C:\Users\polly\Downloads\3462966311412541.eml";
|
||||
let input = std::fs::read(path).unwrap();
|
||||
let message = MessageParser::default().parse(&input).unwrap();
|
||||
for attachment in message.attachments() {
|
||||
@@ -120,6 +122,15 @@ async fn test44() {
|
||||
|
||||
let disposition = attachment.content_disposition();
|
||||
|
||||
let body_start = attachment.raw_body_offset() as usize;
|
||||
let body_end = attachment.raw_end_offset() as usize;
|
||||
|
||||
if body_start < input.len() && body_end <= input.len() && body_start <= body_end {
|
||||
//let raw_data = &input[body_start..body_end];
|
||||
let mut file = std::fs::File::create(&filename).unwrap();
|
||||
file.write_all(attachment.contents()).unwrap();
|
||||
}
|
||||
|
||||
let file_type = format!(
|
||||
"{}/{}",
|
||||
content_type.c_type.as_ref(),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "bichon-ui",
|
||||
"version": "0.0.1",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
IconLayoutDashboard,
|
||||
IconSettings
|
||||
} from '@tabler/icons-react'
|
||||
import { IdCard, Inbox, Mailbox, Search, Users2 } from 'lucide-react'
|
||||
import { IdCard, Inbox, Search, Users2 } from 'lucide-react'
|
||||
import { type SidebarData } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||
@@ -52,11 +52,6 @@ export function useSidebarData(): SidebarData {
|
||||
url: '/accounts',
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: t('navigation.mailbox'),
|
||||
url: '/mailboxes',
|
||||
icon: Mailbox,
|
||||
},
|
||||
{
|
||||
title: t('common.search'),
|
||||
url: '/search',
|
||||
|
||||
@@ -463,7 +463,7 @@ export default function MailArchiveDashboard() {
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 pt-0 text-center text-xs text-muted-foreground">
|
||||
© 2025 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
|
||||
© 2025-2026 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
|
||||
import useMinimalAccountList from "@/hooks/use-minimal-account-list";
|
||||
import { VirtualizedSelect } from "@/components/virtualized-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
onAccountSelect: (accountId: number) => void,
|
||||
defaultAccountId?: number,
|
||||
}
|
||||
|
||||
export function AccountSwitcher({
|
||||
onAccountSelect,
|
||||
defaultAccountId
|
||||
}: AccountSwitcherProps) {
|
||||
const { accountsOptions, isLoading } = useMinimalAccountList();
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation();
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualizedSelect
|
||||
className='w-full mr-8'
|
||||
isLoading={isLoading}
|
||||
options={accountsOptions}
|
||||
defaultValue={`${defaultAccountId}`}
|
||||
onSelectOption={(values) => onAccountSelect(parseInt(values[0], 10))}
|
||||
placeholder={t('oauth2.selectAnAccount')}
|
||||
noItemsComponent={<div className='space-y-2'>
|
||||
<p>No active email account.</p>
|
||||
<Button variant={'outline'} className="py-1 px-3 text-xs" onClick={() => navigate({ to: '/accounts' })}>Add Email Account</Button>
|
||||
</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { X, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useMailboxContext } from '../context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type MailBulkActionsProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selected, setSelected, setOpen, setDeleteIds } = useMailboxContext();
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const selectedCount = selected.size;
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelected(new Set<number>());
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteIds(new Set(selected));
|
||||
setSelected(new Set());
|
||||
setOpen('move-to-trash');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const buttons = toolbarRef.current?.querySelectorAll('button');
|
||||
if (!buttons || buttons.length === 0) return;
|
||||
|
||||
const currentIndex = Array.from(buttons).findIndex(
|
||||
(btn) => btn === document.activeElement
|
||||
);
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight': {
|
||||
e.preventDefault();
|
||||
const next = (currentIndex + 1) % buttons.length;
|
||||
buttons[next]?.focus();
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault();
|
||||
const prev = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1;
|
||||
buttons[prev]?.focus();
|
||||
break;
|
||||
}
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
buttons[0]?.focus();
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
buttons[buttons.length - 1]?.focus();
|
||||
break;
|
||||
case 'Escape': {
|
||||
const target = e.target as HTMLElement;
|
||||
const active = document.activeElement as HTMLElement;
|
||||
const isFromDropdown =
|
||||
target.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
active.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
target.closest('[data-slot="dropdown-menu-content"]') ||
|
||||
active.closest('[data-slot="dropdown-menu-content"]');
|
||||
|
||||
if (!isFromDropdown) {
|
||||
e.preventDefault();
|
||||
handleClearSelection();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
role="toolbar"
|
||||
aria-label={t('mailbox.bulkActions.ariaLabel', {
|
||||
count: selectedCount,
|
||||
emailLabel: selectedCount > 1 ? t('mailbox.bulkActions.emails') : t('mailbox.bulkActions.email')
|
||||
})}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
|
||||
'transition-all delay-100 duration-300 ease-out hover:scale-105',
|
||||
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 shadow-xl rounded-xl border',
|
||||
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
|
||||
'flex items-center gap-x-2'
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleClearSelection}
|
||||
className="size-6 rounded-full"
|
||||
aria-label={t('mailbox.bulkActions.clearSelection')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">{t('mailbox.bulkActions.clearSelection')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mailbox.bulkActions.clearSelectionWithKey', { key: 'Escape' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<div className="flex items-center gap-x-1 text-sm" id="bulk-actions-desc">
|
||||
<Badge variant="default" className="min-w-8 rounded-lg">
|
||||
{selectedCount}
|
||||
</Badge>{' '}
|
||||
<span className="hidden sm:inline">
|
||||
{selectedCount > 1 ? t('mailbox.bulkActions.emails') : t('mailbox.bulkActions.email')}
|
||||
</span>{' '}
|
||||
{t('mailbox.bulkActions.selected')}
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="gap-1"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t('mailbox.bulkActions.delete')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mailbox.bulkActions.deleteTooltip')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { delete_messages } from '@/api/mailbox/envelope/api';
|
||||
import { useMailboxContext } from '../context';
|
||||
import { mapToRecordOfArrays } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedAccountId, deleteIds, setDeleteIds } = useMailboxContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ payload }: { payload: Record<string, number[]> }) => delete_messages(payload),
|
||||
retry: false,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mailbox-list-messages'] });
|
||||
onOpenChange(false);
|
||||
setDeleteIds(new Set());
|
||||
toast({
|
||||
title: t('mailbox.deleteDialog.successTitle'),
|
||||
description: t('mailbox.deleteDialog.successDesc'),
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('mailbox.deleteDialog.errorTitle'),
|
||||
description: `${error.message}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedAccountId) {
|
||||
const body = new Map<number, Set<number>>();
|
||||
body.set(selectedAccountId, deleteIds);
|
||||
const payload = mapToRecordOfArrays(body);
|
||||
deleteMutation.mutate({ payload });
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = deleteMutation.isPending;
|
||||
|
||||
const emailCount = deleteIds.size;
|
||||
const countText =
|
||||
emailCount > 1
|
||||
? t('mailbox.deleteDialog.descCountMultiple', { count: emailCount })
|
||||
: t('mailbox.deleteDialog.descCountSingle');
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
handleConfirm={handleDelete}
|
||||
className="max-w-xl"
|
||||
isLoading={isLoading}
|
||||
title={
|
||||
<span className="text-destructive">
|
||||
<IconAlertTriangle
|
||||
className="mr-1 inline-block stroke-destructive"
|
||||
size={18}
|
||||
/>{' '}
|
||||
{t('mailbox.deleteDialog.title')}
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className="space-y-4">
|
||||
<p className="mb-2">
|
||||
{t('mailbox.deleteDialog.desc', { countText })}
|
||||
</p>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('mailbox.deleteDialog.warningTitle')}</AlertTitle>
|
||||
<AlertDescription>{t('mailbox.deleteDialog.warningDesc')}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText={t('mailbox.deleteDialog.confirm')}
|
||||
destructive
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet'
|
||||
import { useMailboxContext } from '../context'
|
||||
import { MailMessageView } from './mail-message-view'
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function MailDisplayDrawer({ open, onOpenChange }: Props) {
|
||||
const { currentEnvelope } = useMailboxContext();
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<VisuallyHidden asChild>
|
||||
<SheetTitle />
|
||||
</VisuallyHidden>
|
||||
<SheetContent className="md:w-[80rem] h-full p-0">
|
||||
|
||||
<div className='m-5'>
|
||||
{currentEnvelope ? (
|
||||
<MailMessageView envelope={currentEnvelope} />
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">{t('mail.noMessageSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { cn, dateFnsLocaleMap, 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 { EmailEnvelope } from "@/api"
|
||||
import { useMailboxContext } from "../context"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { MailBulkActions } from "./bulk-actions"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function MailList({
|
||||
items,
|
||||
isLoading,
|
||||
}: MailListProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { currentEnvelope, setCurrentEnvelope, setDeleteIds, setOpen, selected, setSelected } = useMailboxContext()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
|
||||
const handleDelete = (envelope: EmailEnvelope) => {
|
||||
setDeleteIds(new Set([envelope.id]))
|
||||
setOpen("move-to-trash")
|
||||
}
|
||||
|
||||
const totalSelected = selected.size;
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const total = selected.size;
|
||||
if (total === items.length && items.length > 0) {
|
||||
setSelected(new Set<number>());
|
||||
} else {
|
||||
const set = new Set<number>();
|
||||
for (const item of items) {
|
||||
set.add(item.id);
|
||||
}
|
||||
setSelected(set);
|
||||
}
|
||||
}
|
||||
|
||||
const hasSelected = (mailId: number) => {
|
||||
return selected.has(mailId);
|
||||
}
|
||||
|
||||
const toggleSelected = (id: number) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{items.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
|
||||
<Checkbox
|
||||
checked={
|
||||
selected.size === items.length && items.length > 0
|
||||
? true
|
||||
: selected.size > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleToggleAll}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selected.size > 0
|
||||
? `${t('search.bulkActions.selected', { count: selected.size })}`
|
||||
: t('common.selectAll')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((item, index) => {
|
||||
const hasAttachments = item.attachment_count > 0
|
||||
const isSelected = currentEnvelope?.id === item.id
|
||||
const isChecked = hasSelected(item.id)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input[type="checkbox"], button')) return
|
||||
setCurrentEnvelope(item);
|
||||
setOpen("display")
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleSelected(item.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 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">
|
||||
<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}
|
||||
</h3>
|
||||
</div>
|
||||
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
|
||||
{item.subject}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mt-0.25">
|
||||
{item.tags?.map((tag, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
className="px-1 py-0.5 text-[10px] h-auto leading-none"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 w-3" />
|
||||
<span>{item.attachment_count}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden md:inline">{formatBytes(item.size)}</span>
|
||||
<span className={cn(
|
||||
isSelected ? "text-foreground font-medium" : "text-muted-foreground"
|
||||
)}>
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
|
||||
</span>
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelected(new Set([item.id]));
|
||||
setOpen("restore");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="ml-2 h-3.5 w-3.5" />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{totalSelected > 0 && <MailBulkActions />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileAudio, FileVideo, FileSpreadsheet, FileArchive, FileCode, FileIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { formatBytes, formatTimestamp } from '@/lib/utils';
|
||||
import { useMailboxContext } from '../context';
|
||||
import EmailIframe from '@/components/mail-iframe';
|
||||
import {
|
||||
AttachmentInfo,
|
||||
download_attachment,
|
||||
download_message,
|
||||
getContent,
|
||||
load_message,
|
||||
} from '@/api/mailbox/envelope/api';
|
||||
import { AxiosError } from 'axios';
|
||||
import { MailThreadDialog } from './thread-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||
|
||||
interface MailMessageViewProps {
|
||||
envelope: {
|
||||
account_id: number;
|
||||
id: number;
|
||||
from?: string;
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
subject?: string;
|
||||
internal_date?: number;
|
||||
};
|
||||
showActions?: boolean;
|
||||
showHeader?: boolean;
|
||||
showAttachments?: boolean;
|
||||
}
|
||||
|
||||
const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines }) => {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start space-x-2">
|
||||
<span className="font-medium text-gray-400 whitespace-nowrap">{title}:</span>
|
||||
<div className="flex-1">
|
||||
<ul className="list-disc list-inside">
|
||||
{lines.slice(0, expanded ? lines.length : 3).map((ref, i) => (
|
||||
<li key={i} className="line-clamp-1">{ref}</li>
|
||||
))}
|
||||
</ul>
|
||||
{lines.length > 3 && (
|
||||
<button
|
||||
className="text-blue-500 hover:underline text-xs"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? t('common.showLess') : t('common.showMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const getFileConfig = (mimeType: string) => {
|
||||
const type = mimeType.toLowerCase();
|
||||
if (type.includes('pdf')) {
|
||||
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
|
||||
}
|
||||
if (type.includes('image/')) {
|
||||
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
|
||||
}
|
||||
if (type.includes('audio/')) {
|
||||
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
|
||||
}
|
||||
|
||||
if (type.includes('video/')) {
|
||||
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
|
||||
}
|
||||
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
|
||||
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
|
||||
}
|
||||
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
|
||||
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
|
||||
}
|
||||
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
|
||||
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
|
||||
}
|
||||
|
||||
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
|
||||
};
|
||||
|
||||
export function MailMessageView({
|
||||
envelope,
|
||||
showActions = true,
|
||||
showAttachments = true,
|
||||
showHeader = true
|
||||
}: MailMessageViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const { selectedAccountId, setDeleteIds, setOpen } = useMailboxContext();
|
||||
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
|
||||
const [attachments, setAttachments] = useState<AttachmentInfo[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [downloadingAttachmentFileName, setDownloadingAttachmentFileName] = useState<string | null>(null);
|
||||
|
||||
const { getEmailById } = useMinimalAccountList();
|
||||
const [threadOpen, setThreadOpen] = useState(false);
|
||||
|
||||
const downloadAttachmentMutation = useMutation({
|
||||
mutationFn: ({ fileName }: { fileName: string }) =>
|
||||
download_attachment(selectedAccountId!, envelope.id, fileName),
|
||||
onSuccess: () => setDownloadingAttachmentFileName(null),
|
||||
onError: (error: any) => {
|
||||
setDownloadingAttachmentFileName(null);
|
||||
toast({
|
||||
title: t('mail.failedToDownloadFile'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const loadMessageMutation = useMutation({
|
||||
mutationFn: () => load_message(selectedAccountId!, envelope.id),
|
||||
onSuccess: (data) => {
|
||||
setLoading(false);
|
||||
setContent(getContent(data));
|
||||
if (data.attachments) setAttachments(data.attachments);
|
||||
setContentType(data.html ? 'Html' : 'Plain');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setLoading(false);
|
||||
toast({
|
||||
title: t('mail.failedToLoadEmail'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id]);
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteIds(new Set([envelope.id]));
|
||||
setOpen('move-to-trash');
|
||||
};
|
||||
|
||||
const downloadEmlFile = async () => {
|
||||
try {
|
||||
toast({ title: t('mail.downloadStarted'), description: t('mail.isBeingDownloaded', { id: envelope.id }) });
|
||||
await download_message(selectedAccountId!, envelope.id);
|
||||
toast({ title: t('mail.downloadComplete'), description: t('mail.downloaded', { id: envelope.id }) });
|
||||
} catch (error) {
|
||||
let msg = t('mail.downloadFailed');
|
||||
if (error instanceof AxiosError) {
|
||||
msg = error.response?.data?.message || error.response?.data?.error || error.message;
|
||||
if (error.response?.status) msg = `${error.response.status}: ${msg}`;
|
||||
} else if (error instanceof Error) {
|
||||
msg = error.message;
|
||||
}
|
||||
toast({ title: t('mail.downloadFailed'), description: msg, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header Info */}
|
||||
{showHeader && <div className="grid gap-1 text-xs">
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.account')}:</span>
|
||||
<span>{getEmailById(envelope.account_id)}</span>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.id')}:</span>
|
||||
<span>{envelope.id}</span>
|
||||
</div>
|
||||
{envelope.from && (
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.from')}:</span>
|
||||
<span>{envelope.from}</span>
|
||||
</div>
|
||||
)}
|
||||
{envelope.to && envelope.to.length > 0 && <Multilines title={t('mail.to')} lines={envelope.to} />}
|
||||
{envelope.cc && envelope.cc.length > 0 && <Multilines title={t('mail.cc')} lines={envelope.cc} />}
|
||||
{envelope.bcc && envelope.bcc.length > 0 && <Multilines title={t('mail.bcc')} lines={envelope.bcc} />}
|
||||
{envelope.subject && (
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.subject')}:</span>
|
||||
<span>{envelope.subject}</span>
|
||||
</div>
|
||||
)}
|
||||
{envelope.internal_date && (
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.date')}:</span>
|
||||
<span>{formatTimestamp(envelope.internal_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
{/* Action Bar */}
|
||||
{showActions && (
|
||||
<>
|
||||
<div className="flex items-center mt-2 space-x-2">
|
||||
<Separator orientation="horizontal" className="flex-1 bg-border" />
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-3 text-xs text-gray-500">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={handleDelete} className="hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mail.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={downloadEmlFile}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mail.download')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setThreadOpen(true)}
|
||||
>
|
||||
<MessageSquareMore className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mail.viewThread')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showAttachments && <Separator className="my-2" />}
|
||||
{/* Attachments */}
|
||||
{showAttachments && (
|
||||
<div className="mb-2">
|
||||
{loading ? (
|
||||
<span className="text-gray-500 text-xs" />
|
||||
) : attachments && attachments.length > 0 ? (
|
||||
(() => {
|
||||
const nonInline = attachments.filter((a) => !a.inline);
|
||||
return nonInline.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{nonInline.map((attachment, i) => {
|
||||
const { icon, color } = getFileConfig(attachment.file_type);
|
||||
return <div key={i} className="flex items-center">
|
||||
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
|
||||
<div className={`flex-shrink-0 ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
|
||||
<span
|
||||
className="truncate text-xs font-medium text-foreground/90"
|
||||
title={attachment.filename}
|
||||
>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase tracking-tighter group-hover:text-foreground transition-colors">
|
||||
{attachment.file_type.split('/').pop()?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 ml-auto">
|
||||
<span className="text-gray-500 text-xs shrink-0">
|
||||
{formatBytes(attachment.size)}
|
||||
</span>
|
||||
{downloadingAttachmentFileName === attachment.filename ? (
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
setDownloadingAttachmentFileName(attachment.filename);
|
||||
downloadAttachmentMutation.mutate({ fileName: attachment.filename });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs italic">
|
||||
{t('mail.onlyNonInlineAttachments')}
|
||||
</span>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs">{t('mail.noAttachments')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showAttachments && <Separator className="mb-2" />}
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader className="w-6 h-6 animate-spin" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">loading...</span>
|
||||
</div>
|
||||
) : content ? (
|
||||
<div className="bg-gray-100 rounded-lg border border-gray-300 p-4">
|
||||
{contentType === 'Html' ? (
|
||||
<EmailIframe emailHtml={content} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-gray-800 text-sm font-sans">{content}</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground text-sm">No content available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { AccountSwitcher } from "./account-switcher"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { list_mailboxes, MailboxData } from "@/api/mailbox/api"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import MailboxProvider, { MailboxDialogType } from "../context"
|
||||
import useDialogState from "@/hooks/use-dialog-state"
|
||||
import { MailboxDialog } from "./mailbox-detail"
|
||||
import { MailList } from "./mail-list"
|
||||
import { list_messages } from "@/api/mailbox/envelope/api"
|
||||
import { MailDisplayDrawer } from "./mail-display-drawer"
|
||||
import { toast } from "@/hooks/use-toast"
|
||||
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, MoreVertical, Trash2 } from "lucide-react"
|
||||
import { RestoreMessageDialog } from "./restore-message-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { MailBoxDeleteDialog } from "./delete-mailbox-dialog"
|
||||
|
||||
|
||||
interface MailProps {
|
||||
defaultLayout: number[] | undefined
|
||||
defaultCollapsed?: boolean
|
||||
navCollapsedSize: number,
|
||||
lastSelectedAccountId?: number | undefined
|
||||
}
|
||||
|
||||
interface ListMessagesOptions {
|
||||
accountId: number | undefined;
|
||||
mailboxId: number | undefined;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
const useListMessages = ({ accountId, mailboxId, page, page_size }: ListMessagesOptions) => {
|
||||
return useQuery({
|
||||
queryKey: ['mailbox-list-messages', `${accountId}`, mailboxId, page, page_size],
|
||||
queryFn: () => {
|
||||
return list_messages(accountId!, mailboxId!, page, page_size);
|
||||
},
|
||||
enabled: !!accountId && !!mailboxId,
|
||||
retry: 0,
|
||||
staleTime: 1000,
|
||||
});
|
||||
};
|
||||
|
||||
interface CustomLabelProps {
|
||||
exists?: number;
|
||||
attributes?: { attr: string; extension: string | null }[],
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
icon?: React.ElementType;
|
||||
expandable?: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function CustomLabel({
|
||||
expandable,
|
||||
exists,
|
||||
attributes,
|
||||
children,
|
||||
id,
|
||||
onDelete,
|
||||
...other
|
||||
}: CustomLabelProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<TreeItemLabel
|
||||
{...other}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<FolderIcon className="mr-2" />
|
||||
<span className="font-medium text-sm text-inherit">
|
||||
{children}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-20">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onDelete(id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t('common.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TreeItemLabel>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
navCollapsedSize,
|
||||
lastSelectedAccountId,
|
||||
}: MailProps) {
|
||||
const [open, setOpen] = useDialogState<MailboxDialogType>(null)
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed)
|
||||
const [selectedMailbox, setSelectedMailbox] = React.useState<MailboxData | undefined>(undefined);
|
||||
const [selectedAccountId, setSelectedAccountId] = React.useState<number | undefined>(lastSelectedAccountId);
|
||||
const [selectedEvelope, setSelectedEvelope] = React.useState<EmailEnvelope | undefined>(undefined);
|
||||
const [page, setPage] = React.useState(0);
|
||||
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 [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
|
||||
|
||||
const { theme } = useTheme()
|
||||
|
||||
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
|
||||
queryKey: ['account-mailboxes', `${selectedAccountId}`],
|
||||
queryFn: () => list_mailboxes(selectedAccountId!, false),
|
||||
enabled: !!selectedAccountId,
|
||||
})
|
||||
|
||||
|
||||
const tree = buildTree(mailboxes ?? []);
|
||||
|
||||
const { data: envelopes, isLoading: isMessagesLoading, isError, error } = useListMessages({
|
||||
accountId: selectedAccountId,
|
||||
mailboxId: selectedMailbox?.id,
|
||||
page: page + 1,
|
||||
page_size: pageSize
|
||||
});
|
||||
|
||||
const hasNextPage = () => {
|
||||
return page + 1 < envelopes?.total_pages!;
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
}
|
||||
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
setPage(0);
|
||||
setPageSize(newSize);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isError && error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to load messages",
|
||||
description: error.message || "An unknown error occurred. Please try again.",
|
||||
});
|
||||
}
|
||||
}, [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 handleItemClick = (
|
||||
_event: React.SyntheticEvent | null,
|
||||
itemId: string
|
||||
) => {
|
||||
//console.log(itemId)
|
||||
setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setDeleteMailboxId(id);
|
||||
setOpen('delete');
|
||||
};
|
||||
|
||||
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,
|
||||
id: item.id,
|
||||
onDelete: handleDeleteClick,
|
||||
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,
|
||||
deleteMailboxId,
|
||||
setDeleteMailboxId
|
||||
}}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
onLayout={(sizes: number[]) => {
|
||||
localStorage.setItem('react-resizable-panels:layout:mail', JSON.stringify(sizes));
|
||||
}}
|
||||
className="items-stretch"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={defaultLayout[0]}
|
||||
collapsedSize={navCollapsedSize}
|
||||
minSize={navCollapsedSize}
|
||||
collapsible={true}
|
||||
onCollapse={() => {
|
||||
setIsCollapsed(true);
|
||||
localStorage.setItem('react-resizable-panels:collapsed', JSON.stringify(true));
|
||||
}}
|
||||
onResize={() => {
|
||||
setIsCollapsed(false);
|
||||
localStorage.setItem('react-resizable-panels:collapsed', JSON.stringify(false));
|
||||
}}
|
||||
className={cn(
|
||||
isCollapsed &&
|
||||
"min-w-[50px] transition-all duration-300 ease-in-out"
|
||||
)}
|
||||
>
|
||||
<Separator className="mb-2" />
|
||||
<ScrollArea className='h-[calc(100vh-8rem)] w-full pr-4 -mr-4 py-1'>
|
||||
<div>
|
||||
<AccountSwitcher onAccountSelect={(accountId) => {
|
||||
localStorage.setItem('mailbox:selectedAccountId', `${accountId}`);
|
||||
setSelectedAccountId(accountId);
|
||||
setSelectedMailbox(undefined);
|
||||
}} defaultAccountId={lastSelectedAccountId} />
|
||||
</div>
|
||||
<Separator className="mt-2" />
|
||||
{isMailboxesLoading ? (
|
||||
<div className="space-y-2 p-4">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
</div>
|
||||
<div className="pl-6 space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, subIndex) => (
|
||||
<div key={subIndex} className="flex items-center space-x-2">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-[150px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RichTreeView
|
||||
//checkboxSelection
|
||||
items={tree}
|
||||
expansionTrigger="iconContainer"
|
||||
onItemClick={handleItemClick}
|
||||
slots={{ item: CustomTreeItem }}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className="h-[calc(100vh-7rem)]" />
|
||||
<ResizablePanel defaultSize={defaultLayout[1]}>
|
||||
{selectedMailbox && <div>
|
||||
<Separator />
|
||||
<div className="flex items-center px-4 py-2">
|
||||
<h2 className="text-xl font-bold cursor-pointer hover:underline" onClick={() => setOpen("mailbox")}>
|
||||
{selectedMailbox?.name}
|
||||
</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="mt-2">
|
||||
<ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1'>
|
||||
<MailList
|
||||
isLoading={isMessagesLoading}
|
||||
items={(envelopes?.items ?? []).sort((a, b) => {
|
||||
const dateA = a.date;
|
||||
const dateB = b.date;
|
||||
return dateB - dateA;
|
||||
})}
|
||||
/>
|
||||
</ScrollArea>
|
||||
{selectedMailbox && <div className="flex justify-center mt-4">
|
||||
<EnvelopeListPagination
|
||||
totalItems={envelopes?.total_items ?? 0}
|
||||
hasNextPage={hasNextPage}
|
||||
pageIndex={page}
|
||||
pageSize={pageSize}
|
||||
setPageIndex={handlePageChange}
|
||||
setPageSize={handlePageSizeChange}
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
</div>}
|
||||
{!selectedMailbox && <div className="flex h-[750px] shrink-0 items-center justify-center rounded-md border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<img
|
||||
src={Logo}
|
||||
className='mb-6 opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100'
|
||||
width={350}
|
||||
height={350}
|
||||
alt='Bichon Logo'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</TooltipProvider>
|
||||
<MailboxDialog
|
||||
key='mailbox-detail'
|
||||
open={open === 'mailbox'}
|
||||
onOpenChange={() => setOpen('mailbox')}
|
||||
/>
|
||||
<MailDisplayDrawer
|
||||
key='mail-display'
|
||||
open={open === 'display'}
|
||||
onOpenChange={() => setOpen('display')}
|
||||
/>
|
||||
<EnvelopeDeleteDialog
|
||||
key='envelope-move-to-trash'
|
||||
open={open === 'move-to-trash'}
|
||||
onOpenChange={() => setOpen('move-to-trash')}
|
||||
/>
|
||||
|
||||
<RestoreMessageDialog
|
||||
key='envelope-restore'
|
||||
open={open === 'restore'}
|
||||
onOpenChange={() => setOpen('restore')}
|
||||
/>
|
||||
<MailBoxDeleteDialog
|
||||
key='mailbox-delete'
|
||||
open={open === 'delete'}
|
||||
onOpenChange={() => setOpen('delete')}
|
||||
/>
|
||||
|
||||
</MailboxProvider >
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
import AceEditor from '@/components/ace-editor'
|
||||
import { useTheme } from '@/context/theme-context'
|
||||
import { MailboxData } from '@/api/mailbox/api'
|
||||
import { useMailboxContext } from '../context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function convertMailboxData(raw: MailboxData): any {
|
||||
|
||||
const attributes: string[] = [];
|
||||
raw.attributes.forEach(item => {
|
||||
attributes.push(item.attr);
|
||||
if (item.attr.toLowerCase() === "Extension" && item.extension !== null) {
|
||||
attributes.push(item.extension);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
// ...raw,
|
||||
id: raw.id.toString(),
|
||||
attributes
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function MailboxDialog({ open, onOpenChange }: Props) {
|
||||
const { theme } = useTheme()
|
||||
const { currentMailbox } = useMailboxContext()
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => {
|
||||
onOpenChange(state)
|
||||
}}
|
||||
>
|
||||
<DialogContent className='w-full md:max-w-xl'>
|
||||
<DialogHeader className='text-left mb-4'>
|
||||
<DialogTitle>{currentMailbox?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AceEditor
|
||||
readOnly={true}
|
||||
value={currentMailbox
|
||||
? JSON.stringify(convertMailboxData(currentMailbox), null, 2)
|
||||
: 'null'}
|
||||
className="h-[14rem]"
|
||||
mode='json'
|
||||
theme={theme === "dark" ? 'monokai' : 'kuroir'}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { restore_message } from '@/api/mailbox/envelope/api'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMailboxContext } from '../context'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
|
||||
interface RestoreMessageDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function RestoreMessageDialog({
|
||||
open,
|
||||
onOpenChange
|
||||
}: RestoreMessageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { selectedAccountId, selected, setSelected } = useMailboxContext();
|
||||
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (messageIds: number[]) =>
|
||||
restore_message(selectedAccountId!, messageIds),
|
||||
onSuccess: handleRestoreSuccess,
|
||||
onError: handleRestoreError,
|
||||
});
|
||||
|
||||
function handleRestoreSuccess() {
|
||||
toast({
|
||||
title: t('restore_message.success', 'Messages restored'),
|
||||
description: t(
|
||||
'restore_message.successDesc',
|
||||
'The selected messages have been restored to the IMAP server.'
|
||||
),
|
||||
action: (
|
||||
<ToastAction altText={t('common.close')}>
|
||||
{t('common.close')}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
setSelected(new Set());
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleRestoreError(error: AxiosError) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
t('restore_message.failed', 'Failed to restore messages');
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t(
|
||||
'restore_message.failedTitle',
|
||||
'Restore failed'
|
||||
),
|
||||
description: errorMessage,
|
||||
action: (
|
||||
<ToastAction altText={t('common.tryAgain')}>
|
||||
{t('common.tryAgain')}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('restore_message.title', 'Restore messages')}
|
||||
desc={t(
|
||||
'restore_message.desc',
|
||||
'This action will append the selected messages from Bichon to their corresponding mailboxes on the IMAP server.'
|
||||
)}
|
||||
confirmText={t('restore_message.confirm', 'Restore')}
|
||||
handleConfirm={() => restoreMutation.mutate(Array.from(selected))}
|
||||
className="sm:max-w-sm"
|
||||
isLoading={restoreMutation.isPending}
|
||||
disabled={restoreMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { ChevronDown, ChevronUp, Loader2, MessageSquareText } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useMailboxContext } from '../context';
|
||||
import { get_thread_messages } from '@/api/mailbox/envelope/api';
|
||||
import { MailMessageView } from './mail-message-view';
|
||||
|
||||
interface MailThreadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedAccountId, currentEnvelope } = useMailboxContext();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const threadId = currentEnvelope?.thread_id;
|
||||
const accountId = selectedAccountId;
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['thread', accountId, threadId],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
get_thread_messages(accountId!, threadId!, pageParam, 10),
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.current_page && lastPage.total_pages
|
||||
? lastPage.current_page < lastPage.total_pages
|
||||
? lastPage.current_page + 1
|
||||
: undefined
|
||||
: undefined,
|
||||
enabled: open && !!accountId && !!threadId,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
const allMessages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
const totalCount = data?.pages[0]?.total_items ?? 0;
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-full p-0 max-h-full flex flex-col md:max-w-3xl lg:max-w-4xl">
|
||||
{/* Header */}
|
||||
<DialogHeader className="p-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquareText className="w-5 h-5" />
|
||||
<div className="text-sm">
|
||||
{t('mailbox.thread.title', {
|
||||
count: totalCount,
|
||||
messageLabel: totalCount === 1 ? t('mailbox.thread.message') : t('mailbox.thread.messages')
|
||||
})}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{isLoading && <ThreadSkeleton />}
|
||||
|
||||
{isError && (
|
||||
<div className="text-center text-destructive text-sm">
|
||||
{t('mailbox.thread.loadError')}: {(error as Error)?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && allMessages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
{t('mailbox.thread.empty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMessages
|
||||
.sort((a, b) => a.date - b.date)
|
||||
.map((msg) => {
|
||||
const isExpanded = expandedIds.has(msg.id);
|
||||
const preview = msg.text?.slice(0, 120) + (msg.text?.length > 120 ? '...' : '');
|
||||
const date = new Date(msg.date);
|
||||
const formattedDate = isNaN(date.getTime())
|
||||
? t('mailbox.thread.invalidDate')
|
||||
: format(date, 'yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={msg.id}
|
||||
className={`transition-all ${isExpanded ? 'ring-2 ring-primary' : ''}`}
|
||||
>
|
||||
<CardHeader
|
||||
className="cursor-pointer pb-3"
|
||||
onClick={() => toggleExpand(msg.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium truncate">{msg.from}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{msg.to.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium mt-1 text-sm">
|
||||
{msg.subject || t('mailbox.thread.noSubject')}
|
||||
</p>
|
||||
{!isExpanded && preview && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formattedDate}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent className="p-0">
|
||||
<div className="h-96 border-t m-5">
|
||||
<MailMessageView
|
||||
envelope={msg}
|
||||
showActions={false}
|
||||
showAttachments={false}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('mailbox.thread.loadingMore')}
|
||||
</>
|
||||
) : (
|
||||
t('mailbox.thread.loadMore')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton
|
||||
function ThreadSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64 mb-1" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-32 mt-2" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import React from 'react'
|
||||
import { MailboxData } from '@/api/mailbox/api'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
|
||||
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' | 'restore' | 'delete'
|
||||
|
||||
interface MailboxContextType {
|
||||
open: MailboxDialogType | null
|
||||
setOpen: (str: MailboxDialogType | null) => void
|
||||
selectedAccountId: number | undefined
|
||||
currentMailbox: MailboxData | undefined
|
||||
currentEnvelope: EmailEnvelope | undefined
|
||||
setCurrentMailbox: React.Dispatch<React.SetStateAction<MailboxData | undefined>>
|
||||
deleteMailboxId: string | undefined,
|
||||
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
setCurrentEnvelope: React.Dispatch<React.SetStateAction<EmailEnvelope | undefined>>
|
||||
deleteIds: Set<number>
|
||||
setDeleteIds: React.Dispatch<React.SetStateAction<Set<number>>>
|
||||
selected: Set<number>
|
||||
setSelected: React.Dispatch<React.SetStateAction<Set<number>>>
|
||||
}
|
||||
|
||||
const MailboxContext = React.createContext<MailboxContextType | null>(null)
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
value: MailboxContextType
|
||||
}
|
||||
|
||||
export default function MailboxProvider({ children, value }: Props) {
|
||||
return <MailboxContext.Provider value={value}>{children}</MailboxContext.Provider>
|
||||
}
|
||||
|
||||
export const useMailboxContext = () => {
|
||||
const mailboxContext = React.useContext(MailboxContext)
|
||||
|
||||
if (!mailboxContext) {
|
||||
throw new Error(
|
||||
'useMailboxContext has to be used within <MailboxContext.Provider>'
|
||||
)
|
||||
}
|
||||
|
||||
return mailboxContext
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { Mail } from "./components/mail"
|
||||
import { Main } from "@/components/layout/main"
|
||||
import { FixedHeader } from "@/components/layout/fixed-header"
|
||||
|
||||
export default function Mailboxes() {
|
||||
const layout = localStorage.getItem("react-resizable-panels:layout:mail")
|
||||
const collapsed = localStorage.getItem("react-resizable-panels:collapsed")
|
||||
|
||||
const defaultLayout = layout ? JSON.parse(layout) : undefined
|
||||
const defaultCollapsed = collapsed ? JSON.parse(collapsed) : undefined
|
||||
|
||||
const lastSelectedAccountId = localStorage.getItem('mailbox:selectedAccountId') ?? undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<Mail
|
||||
defaultLayout={defaultLayout}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
lastSelectedAccountId={lastSelectedAccountId ? parseInt(lastSelectedAccountId) : undefined}
|
||||
navCollapsedSize={2}
|
||||
/>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { AccountPopover } from './account-popover'
|
||||
import { MailboxPopover } from './mailbox-popover'
|
||||
|
||||
export function AccountMailboxFilter() {
|
||||
return (
|
||||
<>
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -138,7 +138,6 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
'flex items-center gap-x-2'
|
||||
)}
|
||||
>
|
||||
{/* Clear Selection */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -153,19 +152,12 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
|
||||
{/* Selected Count */}
|
||||
<div className="flex items-center gap-x-1 text-sm">
|
||||
<Badge variant="default" className="min-w-8 rounded-lg">
|
||||
{selectedCount}
|
||||
</Badge>{' '}
|
||||
<span className="hidden sm:inline">
|
||||
{t('search.bulkActions.selected', { count: selectedCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -176,18 +168,14 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
className="gap-1"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('search.bulkActions.restoreDesc')}
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
{/* Delete */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -197,9 +185,6 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
className="gap-1"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{t('search.bulkActions.delete')}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -21,7 +21,7 @@ import React from 'react'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { SortingState } from '@tanstack/react-table'
|
||||
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore'
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore' | 'delete-mailbox'
|
||||
|
||||
interface SearchContextType {
|
||||
open: SearchDialogType | null
|
||||
@@ -32,6 +32,10 @@ interface SearchContextType {
|
||||
setToDelete: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
|
||||
selected: Map<number, Set<number>>
|
||||
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
|
||||
deleteMailboxId: string | undefined
|
||||
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
selectedAccountId: number | undefined
|
||||
setSelectedAccountId: React.Dispatch<React.SetStateAction<number | undefined>>
|
||||
selectedTags: string[]
|
||||
sorting: SortingState
|
||||
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
|
||||
|
||||
+3
-3
@@ -21,9 +21,9 @@ import { toast } from '@/hooks/use-toast';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMailboxContext } from '../context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { delete_mailbox } from '@/api/mailbox/api';
|
||||
import { useSearchContext } from './context';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -32,7 +32,7 @@ interface Props {
|
||||
|
||||
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useMailboxContext();
|
||||
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useSearchContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -40,7 +40,7 @@ export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
|
||||
delete_mailbox(accountId, mailboxId),
|
||||
retry: false,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-mailboxes', `${selectedAccountId}`] });
|
||||
queryClient.invalidateQueries({ queryKey: ['search-mailboxes', selectedAccountId] });
|
||||
onOpenChange(false);
|
||||
setDeleteMailboxId(undefined);
|
||||
toast({
|
||||
@@ -33,6 +33,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { RestoreMessageDialog } from './restore-message-dialog';
|
||||
import { MailListTable } from './mail-list-table';
|
||||
import { SortingState } from '@tanstack/react-table';
|
||||
import { MailBoxDeleteDialog } from './delete-mailbox-dialog';
|
||||
|
||||
export default function Search() {
|
||||
const { t } = useTranslation()
|
||||
@@ -42,6 +43,8 @@ export default function Search() {
|
||||
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
|
||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
|
||||
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
|
||||
const [selectedAccountId, setSelectedAccountId] = React.useState<number | undefined>(undefined);
|
||||
|
||||
const {
|
||||
emails,
|
||||
@@ -90,6 +93,10 @@ export default function Search() {
|
||||
setSorting,
|
||||
filter,
|
||||
setFilter,
|
||||
deleteMailboxId,
|
||||
setDeleteMailboxId,
|
||||
selectedAccountId,
|
||||
setSelectedAccountId,
|
||||
handleTagToggle
|
||||
}}
|
||||
>
|
||||
@@ -152,6 +159,12 @@ export default function Search() {
|
||||
open={open === 'restore'}
|
||||
onOpenChange={() => setOpen('restore')}
|
||||
/>
|
||||
|
||||
<MailBoxDeleteDialog
|
||||
key='mailbox-delete'
|
||||
open={open === 'delete-mailbox'}
|
||||
onOpenChange={() => setOpen('delete-mailbox')}
|
||||
/>
|
||||
</SearchProvider>
|
||||
</Main>
|
||||
</>
|
||||
|
||||
@@ -252,7 +252,7 @@ export function MailList({
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
{t('restore_message.restore_to_imap')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
||||
@@ -1,263 +1,380 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronDown, Folders, X } from 'lucide-react'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ChevronDown, Folders, X, TreeDeciduous, FolderIcon,
|
||||
MoreVertical, Trash2, Search,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import { TransitionProps } from '@mui/material/transitions';
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
TreeItemCheckbox,
|
||||
TreeItemContent,
|
||||
TreeItemDragAndDropOverlay,
|
||||
TreeItemIcon,
|
||||
TreeItemIconContainer,
|
||||
TreeItemLabel,
|
||||
TreeItemProvider,
|
||||
TreeItemRoot,
|
||||
useTreeItemModel,
|
||||
} from '@mui/x-tree-view';
|
||||
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
|
||||
import { useTreeItem, UseTreeItemParameters } from '@mui/x-tree-view/useTreeItem';
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { useSearchContext } from './context'
|
||||
import { list_mailboxes } from '@/api/mailbox/api';
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||
import { useSearchContext } from './context';
|
||||
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree';
|
||||
|
||||
const CustomCollapse = styled(Collapse)({ padding: 0 });
|
||||
const AnimatedCollapse = animated(CustomCollapse);
|
||||
|
||||
function TransitionComponent(props: TransitionProps) {
|
||||
const style = useSpring({
|
||||
to: {
|
||||
opacity: props.in ? 1 : 0,
|
||||
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
|
||||
},
|
||||
});
|
||||
return <AnimatedCollapse style={style} {...props} />;
|
||||
}
|
||||
|
||||
interface CustomTreeItemProps
|
||||
extends Omit<UseTreeItemParameters, 'rootRef'>,
|
||||
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
|
||||
|
||||
interface CustomLabelProps {
|
||||
exists?: number;
|
||||
attributes?: { attr: string; extension: string | null }[],
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
icon?: React.ElementType;
|
||||
expandable?: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function CustomLabel({
|
||||
expandable,
|
||||
exists,
|
||||
attributes,
|
||||
children,
|
||||
id,
|
||||
onDelete,
|
||||
...other
|
||||
}: CustomLabelProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<TreeItemLabel
|
||||
{...other}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<FolderIcon className="mr-2" />
|
||||
<span className="font-medium text-sm text-inherit">
|
||||
{children}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-20">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onDelete(id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t('common.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TreeItemLabel>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailboxPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { minimalList = [] } = useMinimalAccountList()
|
||||
const { t } = useTranslation();
|
||||
const { filter, setFilter, setOpen, setDeleteMailboxId, setSelectedAccountId } = useSearchContext();
|
||||
const { minimalList = [] } = useMinimalAccountList();
|
||||
|
||||
const [search, setSearch] = React.useState('')
|
||||
const [localOpen, setLocalOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
const accountIds: number[] = filter.account_ids ?? []
|
||||
const selectedMailboxIds: number[] = filter.mailbox_ids ?? []
|
||||
const accountIds: number[] = filter.account_ids ?? [];
|
||||
const selectedMailboxIds: number[] = filter.mailbox_ids ?? [];
|
||||
|
||||
const { mailboxes, isLoading } = useQueries({
|
||||
queries: accountIds.map(id => ({
|
||||
queryKey: ['search-mailboxes', id],
|
||||
queryFn: () => list_mailboxes(id, false),
|
||||
enabled: accountIds.length > 0,
|
||||
})),
|
||||
combine: results => ({
|
||||
mailboxes: results.flatMap(r => r.data ?? []),
|
||||
isLoading: results.some(r => r.isLoading),
|
||||
}),
|
||||
})
|
||||
const [localSelectedIds, setLocalSelectedIds] = React.useState<number[]>([]);
|
||||
const [activeAccountId, setActiveAccountId] = React.useState<number | undefined>(undefined);
|
||||
|
||||
const toggleMailbox = (id: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const set = new Set<number>(next.mailbox_ids ?? [])
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
set.has(id) ? set.delete(id) : set.add(id)
|
||||
React.useEffect(() => {
|
||||
if (localOpen) {
|
||||
const globalMailboxIds = filter.mailbox_ids ?? [];
|
||||
setLocalSelectedIds(globalMailboxIds);
|
||||
|
||||
const ids = Array.from(set)
|
||||
|
||||
if (ids.length === 0) delete next.mailbox_ids
|
||||
else next.mailbox_ids = ids
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAllMailboxes = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.mailbox_ids
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const grouped = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const map = new Map<number, MailboxData[]>()
|
||||
|
||||
for (const mb of mailboxes) {
|
||||
if (q && !mb.name.toLowerCase().includes(q)) continue
|
||||
if (!map.has(mb.account_id)) map.set(mb.account_id, [])
|
||||
map.get(mb.account_id)!.push(mb)
|
||||
const currentAccountIds = filter.account_ids ?? [];
|
||||
if (currentAccountIds.length > 0) {
|
||||
if (!activeAccountId || !currentAccountIds.includes(activeAccountId)) {
|
||||
setActiveAccountId(currentAccountIds[0]);
|
||||
}
|
||||
} else {
|
||||
setActiveAccountId(undefined);
|
||||
}
|
||||
}
|
||||
}, [localOpen, activeAccountId, filter.account_ids, filter.mailbox_ids]);
|
||||
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => {
|
||||
const aSel = selectedMailboxIds.includes(a.id)
|
||||
const bSel = selectedMailboxIds.includes(b.id)
|
||||
if (aSel && !bSel) return -1
|
||||
if (!aSel && bSel) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
const { data: activeMailboxes = [], isLoading: activeIsLoading } = useQuery({
|
||||
queryKey: ['search-mailboxes', activeAccountId],
|
||||
queryFn: () => list_mailboxes(activeAccountId!, false),
|
||||
enabled: !!activeAccountId,
|
||||
});
|
||||
|
||||
return Array.from(map.entries())
|
||||
}, [mailboxes, search, selectedMailboxIds])
|
||||
const treeData = React.useMemo(() => {
|
||||
const filtered = search.trim()
|
||||
? activeMailboxes.filter(m => m.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: activeMailboxes;
|
||||
return buildTree(filtered);
|
||||
}, [activeMailboxes, search]);
|
||||
|
||||
const defaultOpen = grouped
|
||||
.filter(([, boxes]) =>
|
||||
boxes.some(m => selectedMailboxIds.includes(m.id))
|
||||
)
|
||||
.map(([id]) => id.toString())
|
||||
const disabled = accountIds.length === 0;
|
||||
|
||||
const getAccountEmail = (id: number) =>
|
||||
minimalList.find(a => a.id === id)?.email ?? ''
|
||||
const handleApply = () => {
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
mailbox_ids: localSelectedIds.length > 0 ? localSelectedIds : undefined
|
||||
}));
|
||||
setLocalOpen(false);
|
||||
};
|
||||
|
||||
const disabled = accountIds.length === 0
|
||||
const handleDeleteClick = (id: string) => {
|
||||
console.log("delete=", id);
|
||||
setDeleteMailboxId(id);
|
||||
setSelectedAccountId(activeAccountId);
|
||||
setOpen('delete-mailbox');
|
||||
};
|
||||
|
||||
|
||||
const CustomTreeItem = React.forwardRef(function CustomTreeItem(
|
||||
props: CustomTreeItemProps,
|
||||
ref: React.Ref<HTMLLIElement>,
|
||||
) {
|
||||
const { id, itemId, label, disabled, children, ...other } = props;
|
||||
const {
|
||||
getContextProviderProps,
|
||||
getRootProps,
|
||||
getContentProps,
|
||||
getLabelProps,
|
||||
getIconContainerProps,
|
||||
getCheckboxProps,
|
||||
getGroupTransitionProps,
|
||||
getDragAndDropOverlayProps,
|
||||
status,
|
||||
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
|
||||
|
||||
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
|
||||
|
||||
|
||||
return (
|
||||
<TreeItemProvider {...getContextProviderProps()}>
|
||||
<TreeItemRoot {...getRootProps(other)} className="group">
|
||||
<TreeItemContent {...getContentProps()} sx={{ paddingY: '2px' }}>
|
||||
<TreeItemIconContainer {...getIconContainerProps()}>
|
||||
<TreeItemIcon status={status} />
|
||||
</TreeItemIconContainer>
|
||||
<TreeItemCheckbox {...getCheckboxProps()} sx={{
|
||||
color: 'hsl(var(--muted-foreground) / 0.4)',
|
||||
'&.Mui-checked': {
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
}} />
|
||||
<CustomLabel
|
||||
{...getLabelProps({
|
||||
exists: item.exists,
|
||||
id: item.id,
|
||||
onDelete: handleDeleteClick,
|
||||
attributes: item.attributes,
|
||||
expandable: status.expandable && status.expanded,
|
||||
})}
|
||||
/>
|
||||
|
||||
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
|
||||
</TreeItemContent>
|
||||
{children && <TransitionComponent {...getGroupTransitionProps()} />}
|
||||
</TreeItemRoot>
|
||||
</TreeItemProvider>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={localOpen} onOpenChange={setLocalOpen} >
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5',
|
||||
selectedMailboxIds.length > 0 &&
|
||||
'bg-primary/10 text-primary'
|
||||
'h-8 rounded-none px-3 gap-1.5 transition-colors',
|
||||
selectedMailboxIds.length > 0 && 'bg-primary/10 text-primary border-primary/20'
|
||||
)}
|
||||
>
|
||||
<Folders className="h-4 w-4" />
|
||||
{t('search_mailbox.label')}
|
||||
<span className="max-w-[100px] truncate">{t('search_mailbox.label')}</span>
|
||||
{selectedMailboxIds.length > 0 && (
|
||||
<span className="ml-1 text-xs opacity-70">
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
|
||||
{selectedMailboxIds.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="min-w-[260px] w-fit max-w-[620px] p-1">
|
||||
<div className="p-1 pb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('search_mailbox.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{selectedMailboxIds.length > 0 && (
|
||||
<div className="px-1 pb-2">
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[740px] max-w-[95vw] p-0 flex flex-col h-[480px] shadow-xl border-muted"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-2 border-b bg-muted/10">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('search_mailbox.search_placeholder')}
|
||||
className="h-9 pl-8 text-xs bg-background"
|
||||
/>
|
||||
</div>
|
||||
{localSelectedIds.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllMailboxes}
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={() => setLocalSelectedIds([])}
|
||||
className="h-9 text-xs text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X className="mr-2 h-3.5 w-3.5" />
|
||||
{t('search_mailbox.clear_mailboxes')} ({selectedMailboxIds.length})
|
||||
<X className="mr-1.5 h-3 w-3" />
|
||||
{t('common.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{disabled ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('search_mailbox.select_account_first')}
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<div className="space-y-2 p-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 rounded bg-muted animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : grouped.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('search_mailbox.no_mailbox_found')}
|
||||
</p>
|
||||
) : (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={defaultOpen}
|
||||
className="space-y-1"
|
||||
>
|
||||
{grouped.map(([accountId, boxes]) => {
|
||||
const selectedCount = boxes.filter(b =>
|
||||
selectedMailboxIds.includes(b.id)
|
||||
).length
|
||||
)}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
key={accountId}
|
||||
value={accountId.toString()}
|
||||
>
|
||||
<AccordionTrigger className="text-xs px-2 py-1.5">
|
||||
<span className="truncate">
|
||||
{getAccountEmail(accountId)}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-64 border-r bg-muted/20 flex flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{accountIds.map(id => {
|
||||
const acc = minimalList.find(a => a.id === id);
|
||||
const isActive = activeAccountId === id;
|
||||
const cachedData = queryClient.getQueryData<any[]>(['search-mailboxes', id]);
|
||||
const count = cachedData?.filter(m => localSelectedIds.includes(m.id)).length ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveAccountId(id)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 text-left rounded-md transition-all",
|
||||
isActive
|
||||
? "bg-background shadow-sm text-primary ring-1 ring-black/5"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs truncate font-medium">
|
||||
{acc?.email}
|
||||
</span>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<span className="ml-2 text-[10px] text-primary">
|
||||
{selectedCount}
|
||||
{count > 0 && (
|
||||
<span className="text-[10px] font-bold bg-primary/10 px-1.5 py-0.5 rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</AccordionTrigger>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col bg-background">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3">
|
||||
{activeIsLoading ? (
|
||||
<div className="p-4 space-y-4">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-3 bg-muted animate-pulse rounded w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : activeAccountId ? (
|
||||
<RichTreeView
|
||||
multiSelect
|
||||
items={treeData}
|
||||
checkboxSelection
|
||||
expansionTrigger="iconContainer"
|
||||
selectedItems={localSelectedIds.map(String)}
|
||||
onSelectedItemsChange={(_, itemIds) => {
|
||||
setLocalSelectedIds(itemIds.map(id => parseInt(id)).filter(id => !isNaN(id)));
|
||||
}}
|
||||
slots={{ item: CustomTreeItem }}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-40">
|
||||
<TreeDeciduous className="h-12 w-12 mb-2 stroke-[1px]" />
|
||||
<p className="text-xs">{t('search_mailbox.select_account_tip')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<AccordionContent>
|
||||
<div className="space-y-0.5">
|
||||
{boxes.map(mailbox => {
|
||||
const checked =
|
||||
selectedMailboxIds.includes(mailbox.id)
|
||||
|
||||
return (
|
||||
<TooltipProvider key={mailbox.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() =>
|
||||
toggleMailbox(mailbox.id)
|
||||
}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
||||
'hover:bg-accent transition-colors',
|
||||
checked &&
|
||||
'bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
toggleMailbox(mailbox.id)
|
||||
}
|
||||
onClick={e =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
/>
|
||||
|
||||
<span className="text-xs truncate">
|
||||
{mailbox.name}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="right">
|
||||
<div className="text-sm break-all">
|
||||
{mailbox.name}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<div className="p-3 border-t bg-muted/10 flex items-center justify-between">
|
||||
<div className="text-[10px] text-muted-foreground font-medium">
|
||||
{t('search_mailbox.selected_total')}: <span className="text-foreground">{localSelectedIds.length}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setLocalOpen(false)} className="h-8 px-3 text-xs">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApply} className="h-8 px-4 text-xs gap-1.5 shadow-sm">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('common.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
</Popover >
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
import { TagFilterPopover } from '../tag-filter-popover'
|
||||
import { AccountMailboxFilter } from '../account-mailbox-filter'
|
||||
import { TimePopover } from '../time-popover'
|
||||
import { MailFilterPopover } from '../contact-popover'
|
||||
import { TextSearchInput } from '../text-search-input'
|
||||
import { MoreFiltersPopover } from '../more-filters-popover'
|
||||
import { FilterResetButton } from '../filter-reset'
|
||||
import { MailboxPopover } from '../mailbox-popover'
|
||||
import { AccountPopover } from '../account-popover'
|
||||
|
||||
type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
@@ -25,9 +26,10 @@ export function DataTableToolbar<TData>({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap lg:justify-end">
|
||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap">
|
||||
<TagFilterPopover />
|
||||
<AccountMailboxFilter />
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
<MailFilterPopover />
|
||||
<TagFilterPopover />
|
||||
<TimePopover />
|
||||
<MoreFiltersPopover />
|
||||
<FilterResetButton />
|
||||
|
||||
@@ -144,8 +144,6 @@ export const TokenCardList: React.FC<Props> = ({ tokens, userId }) => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<span className="block font-medium text-foreground">
|
||||
|
||||
@@ -46,9 +46,6 @@ const AuthenticatedOauth2IndexLazyImport = createFileRoute(
|
||||
const AuthenticatedOauth2ResultIndexLazyImport = createFileRoute(
|
||||
'/_authenticated/oauth2-result/',
|
||||
)()
|
||||
const AuthenticatedMailboxesIndexLazyImport = createFileRoute(
|
||||
'/_authenticated/mailboxes/',
|
||||
)()
|
||||
const AuthenticatedApiDocsIndexLazyImport = createFileRoute(
|
||||
'/_authenticated/api-docs/',
|
||||
)()
|
||||
@@ -207,15 +204,6 @@ const AuthenticatedOauth2ResultIndexLazyRoute =
|
||||
),
|
||||
)
|
||||
|
||||
const AuthenticatedMailboxesIndexLazyRoute =
|
||||
AuthenticatedMailboxesIndexLazyImport.update({
|
||||
id: '/mailboxes/',
|
||||
path: '/mailboxes/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/_authenticated/mailboxes/index.lazy').then((d) => d.Route),
|
||||
)
|
||||
|
||||
const AuthenticatedApiDocsIndexLazyRoute =
|
||||
AuthenticatedApiDocsIndexLazyImport.update({
|
||||
id: '/api-docs/',
|
||||
@@ -451,13 +439,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedApiDocsIndexLazyImport
|
||||
parentRoute: typeof AuthenticatedRouteImport
|
||||
}
|
||||
'/_authenticated/mailboxes/': {
|
||||
id: '/_authenticated/mailboxes/'
|
||||
path: '/mailboxes'
|
||||
fullPath: '/mailboxes'
|
||||
preLoaderRoute: typeof AuthenticatedMailboxesIndexLazyImport
|
||||
parentRoute: typeof AuthenticatedRouteImport
|
||||
}
|
||||
'/_authenticated/oauth2-result/': {
|
||||
id: '/_authenticated/oauth2-result/'
|
||||
path: '/oauth2-result'
|
||||
@@ -550,7 +531,6 @@ interface AuthenticatedRouteRouteChildren {
|
||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||
AuthenticatedAccountsIndexLazyRoute: typeof AuthenticatedAccountsIndexLazyRoute
|
||||
AuthenticatedApiDocsIndexLazyRoute: typeof AuthenticatedApiDocsIndexLazyRoute
|
||||
AuthenticatedMailboxesIndexLazyRoute: typeof AuthenticatedMailboxesIndexLazyRoute
|
||||
AuthenticatedOauth2ResultIndexLazyRoute: typeof AuthenticatedOauth2ResultIndexLazyRoute
|
||||
AuthenticatedOauth2IndexLazyRoute: typeof AuthenticatedOauth2IndexLazyRoute
|
||||
AuthenticatedSearchIndexLazyRoute: typeof AuthenticatedSearchIndexLazyRoute
|
||||
@@ -564,7 +544,6 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||
AuthenticatedAccountsIndexLazyRoute: AuthenticatedAccountsIndexLazyRoute,
|
||||
AuthenticatedApiDocsIndexLazyRoute: AuthenticatedApiDocsIndexLazyRoute,
|
||||
AuthenticatedMailboxesIndexLazyRoute: AuthenticatedMailboxesIndexLazyRoute,
|
||||
AuthenticatedOauth2ResultIndexLazyRoute:
|
||||
AuthenticatedOauth2ResultIndexLazyRoute,
|
||||
AuthenticatedOauth2IndexLazyRoute: AuthenticatedOauth2IndexLazyRoute,
|
||||
@@ -594,7 +573,6 @@ export interface FileRoutesByFullPath {
|
||||
'/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
||||
'/accounts': typeof AuthenticatedAccountsIndexLazyRoute
|
||||
'/api-docs': typeof AuthenticatedApiDocsIndexLazyRoute
|
||||
'/mailboxes': typeof AuthenticatedMailboxesIndexLazyRoute
|
||||
'/oauth2-result': typeof AuthenticatedOauth2ResultIndexLazyRoute
|
||||
'/oauth2': typeof AuthenticatedOauth2IndexLazyRoute
|
||||
'/search': typeof AuthenticatedSearchIndexLazyRoute
|
||||
@@ -619,7 +597,6 @@ export interface FileRoutesByTo {
|
||||
'/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
||||
'/accounts': typeof AuthenticatedAccountsIndexLazyRoute
|
||||
'/api-docs': typeof AuthenticatedApiDocsIndexLazyRoute
|
||||
'/mailboxes': typeof AuthenticatedMailboxesIndexLazyRoute
|
||||
'/oauth2-result': typeof AuthenticatedOauth2ResultIndexLazyRoute
|
||||
'/oauth2': typeof AuthenticatedOauth2IndexLazyRoute
|
||||
'/search': typeof AuthenticatedSearchIndexLazyRoute
|
||||
@@ -649,7 +626,6 @@ export interface FileRoutesById {
|
||||
'/_authenticated/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
||||
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexLazyRoute
|
||||
'/_authenticated/api-docs/': typeof AuthenticatedApiDocsIndexLazyRoute
|
||||
'/_authenticated/mailboxes/': typeof AuthenticatedMailboxesIndexLazyRoute
|
||||
'/_authenticated/oauth2-result/': typeof AuthenticatedOauth2ResultIndexLazyRoute
|
||||
'/_authenticated/oauth2/': typeof AuthenticatedOauth2IndexLazyRoute
|
||||
'/_authenticated/search/': typeof AuthenticatedSearchIndexLazyRoute
|
||||
@@ -679,7 +655,6 @@ export interface FileRouteTypes {
|
||||
| '/users/roles'
|
||||
| '/accounts'
|
||||
| '/api-docs'
|
||||
| '/mailboxes'
|
||||
| '/oauth2-result'
|
||||
| '/oauth2'
|
||||
| '/search'
|
||||
@@ -703,7 +678,6 @@ export interface FileRouteTypes {
|
||||
| '/users/roles'
|
||||
| '/accounts'
|
||||
| '/api-docs'
|
||||
| '/mailboxes'
|
||||
| '/oauth2-result'
|
||||
| '/oauth2'
|
||||
| '/search'
|
||||
@@ -731,7 +705,6 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/users/roles'
|
||||
| '/_authenticated/accounts/'
|
||||
| '/_authenticated/api-docs/'
|
||||
| '/_authenticated/mailboxes/'
|
||||
| '/_authenticated/oauth2-result/'
|
||||
| '/_authenticated/oauth2/'
|
||||
| '/_authenticated/search/'
|
||||
@@ -790,7 +763,6 @@ export const routeTree = rootRoute
|
||||
"/_authenticated/",
|
||||
"/_authenticated/accounts/",
|
||||
"/_authenticated/api-docs/",
|
||||
"/_authenticated/mailboxes/",
|
||||
"/_authenticated/oauth2-result/",
|
||||
"/_authenticated/oauth2/",
|
||||
"/_authenticated/search/"
|
||||
@@ -878,10 +850,6 @@ export const routeTree = rootRoute
|
||||
"filePath": "_authenticated/api-docs/index.lazy.tsx",
|
||||
"parent": "/_authenticated"
|
||||
},
|
||||
"/_authenticated/mailboxes/": {
|
||||
"filePath": "_authenticated/mailboxes/index.lazy.tsx",
|
||||
"parent": "/_authenticated"
|
||||
},
|
||||
"/_authenticated/oauth2-result/": {
|
||||
"filePath": "_authenticated/oauth2-result/index.lazy.tsx",
|
||||
"parent": "/_authenticated"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||
import Mailboxes from '@/features/mailbox'
|
||||
|
||||
export const Route = createLazyFileRoute('/_authenticated/mailboxes/')({
|
||||
component: Mailboxes,
|
||||
})
|
||||
Reference in New Issue
Block a user