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
|
// 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/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
use mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders};
|
use mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -108,7 +110,7 @@ R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test44() {
|
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 input = std::fs::read(path).unwrap();
|
||||||
let message = MessageParser::default().parse(&input).unwrap();
|
let message = MessageParser::default().parse(&input).unwrap();
|
||||||
for attachment in message.attachments() {
|
for attachment in message.attachments() {
|
||||||
@@ -120,6 +122,15 @@ async fn test44() {
|
|||||||
|
|
||||||
let disposition = attachment.content_disposition();
|
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!(
|
let file_type = format!(
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
content_type.c_type.as_ref(),
|
content_type.c_type.as_ref(),
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"name": "bichon-ui",
|
"name": "bichon-ui",
|
||||||
"version": "0.0.1",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
IconLayoutDashboard,
|
IconLayoutDashboard,
|
||||||
IconSettings
|
IconSettings
|
||||||
} from '@tabler/icons-react'
|
} 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 { type SidebarData } from '../types'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||||
@@ -52,11 +52,6 @@ export function useSidebarData(): SidebarData {
|
|||||||
url: '/accounts',
|
url: '/accounts',
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('navigation.mailbox'),
|
|
||||||
url: '/mailboxes',
|
|
||||||
icon: Mailbox,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: t('common.search'),
|
title: t('common.search'),
|
||||||
url: '/search',
|
url: '/search',
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ export default function MailArchiveDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 md:p-8 pt-0 text-center text-xs text-muted-foreground">
|
<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>
|
</div>
|
||||||
</Main>
|
</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'
|
'flex items-center gap-x-2'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Clear Selection */}
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -153,19 +152,12 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<Separator orientation="vertical" className="h-5" />
|
||||||
|
|
||||||
{/* Selected Count */}
|
|
||||||
<div className="flex items-center gap-x-1 text-sm">
|
<div className="flex items-center gap-x-1 text-sm">
|
||||||
<Badge variant="default" className="min-w-8 rounded-lg">
|
<Badge variant="default" className="min-w-8 rounded-lg">
|
||||||
{selectedCount}
|
{selectedCount}
|
||||||
</Badge>{' '}
|
</Badge>{' '}
|
||||||
<span className="hidden sm:inline">
|
|
||||||
{t('search.bulkActions.selected', { count: selectedCount })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<Separator orientation="vertical" className="h-5" />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -176,18 +168,14 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
|||||||
className="gap-1"
|
className="gap-1"
|
||||||
>
|
>
|
||||||
<Upload className="h-3.5 w-3.5" />
|
<Upload className="h-3.5 w-3.5" />
|
||||||
<span className="hidden sm:inline">
|
|
||||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t('search.bulkActions.restoreDesc')}
|
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<Separator orientation="vertical" className="h-5" />
|
||||||
{/* Delete */}
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -197,9 +185,6 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
|||||||
className="gap-1"
|
className="gap-1"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<span className="hidden sm:inline">
|
|
||||||
{t('search.bulkActions.delete')}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import React from 'react'
|
|||||||
import { EmailEnvelope } from '@/api'
|
import { EmailEnvelope } from '@/api'
|
||||||
import { SortingState } from '@tanstack/react-table'
|
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 {
|
interface SearchContextType {
|
||||||
open: SearchDialogType | null
|
open: SearchDialogType | null
|
||||||
@@ -32,6 +32,10 @@ interface SearchContextType {
|
|||||||
setToDelete: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
|
setToDelete: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
|
||||||
selected: Map<number, Set<number>>
|
selected: Map<number, Set<number>>
|
||||||
setSelected: React.Dispatch<React.SetStateAction<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[]
|
selectedTags: string[]
|
||||||
sorting: SortingState
|
sorting: SortingState
|
||||||
setSorting: React.Dispatch<React.SetStateAction<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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useMailboxContext } from '../context';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { delete_mailbox } from '@/api/mailbox/api';
|
import { delete_mailbox } from '@/api/mailbox/api';
|
||||||
|
import { useSearchContext } from './context';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,7 +32,7 @@ interface Props {
|
|||||||
|
|
||||||
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
|
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useMailboxContext();
|
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useSearchContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -40,7 +40,7 @@ export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
|
|||||||
delete_mailbox(accountId, mailboxId),
|
delete_mailbox(accountId, mailboxId),
|
||||||
retry: false,
|
retry: false,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['account-mailboxes', `${selectedAccountId}`] });
|
queryClient.invalidateQueries({ queryKey: ['search-mailboxes', selectedAccountId] });
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setDeleteMailboxId(undefined);
|
setDeleteMailboxId(undefined);
|
||||||
toast({
|
toast({
|
||||||
@@ -33,6 +33,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { RestoreMessageDialog } from './restore-message-dialog';
|
import { RestoreMessageDialog } from './restore-message-dialog';
|
||||||
import { MailListTable } from './mail-list-table';
|
import { MailListTable } from './mail-list-table';
|
||||||
import { SortingState } from '@tanstack/react-table';
|
import { SortingState } from '@tanstack/react-table';
|
||||||
|
import { MailBoxDeleteDialog } from './delete-mailbox-dialog';
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -42,6 +43,8 @@ export default function Search() {
|
|||||||
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
|
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
|
||||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
|
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 {
|
const {
|
||||||
emails,
|
emails,
|
||||||
@@ -90,6 +93,10 @@ export default function Search() {
|
|||||||
setSorting,
|
setSorting,
|
||||||
filter,
|
filter,
|
||||||
setFilter,
|
setFilter,
|
||||||
|
deleteMailboxId,
|
||||||
|
setDeleteMailboxId,
|
||||||
|
selectedAccountId,
|
||||||
|
setSelectedAccountId,
|
||||||
handleTagToggle
|
handleTagToggle
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -152,6 +159,12 @@ export default function Search() {
|
|||||||
open={open === 'restore'}
|
open={open === 'restore'}
|
||||||
onOpenChange={() => setOpen('restore')}
|
onOpenChange={() => setOpen('restore')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MailBoxDeleteDialog
|
||||||
|
key='mailbox-delete'
|
||||||
|
open={open === 'delete-mailbox'}
|
||||||
|
onOpenChange={() => setOpen('delete-mailbox')}
|
||||||
|
/>
|
||||||
</SearchProvider>
|
</SearchProvider>
|
||||||
</Main>
|
</Main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ export function MailList({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
<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>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
|
|||||||
@@ -1,263 +1,380 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { ChevronDown, Folders, X } from 'lucide-react'
|
import {
|
||||||
import { useQueries } from '@tanstack/react-query'
|
ChevronDown, Folders, X, TreeDeciduous, FolderIcon,
|
||||||
import { useTranslation } from 'react-i18next'
|
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 {
|
import {
|
||||||
Popover,
|
TreeItemCheckbox,
|
||||||
PopoverContent,
|
TreeItemContent,
|
||||||
PopoverTrigger,
|
TreeItemDragAndDropOverlay,
|
||||||
} from '@/components/ui/popover'
|
TreeItemIcon,
|
||||||
import {
|
TreeItemIconContainer,
|
||||||
Accordion,
|
TreeItemLabel,
|
||||||
AccordionContent,
|
TreeItemProvider,
|
||||||
AccordionItem,
|
TreeItemRoot,
|
||||||
AccordionTrigger,
|
useTreeItemModel,
|
||||||
} from '@/components/ui/accordion'
|
} 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
DropdownMenu,
|
||||||
TooltipContent,
|
DropdownMenuContent,
|
||||||
TooltipProvider,
|
DropdownMenuItem,
|
||||||
TooltipTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
|
import { list_mailboxes } from '@/api/mailbox/api';
|
||||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||||
import { useSearchContext } from './context'
|
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() {
|
export function MailboxPopover() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const { filter, setFilter } = useSearchContext()
|
const { filter, setFilter, setOpen, setDeleteMailboxId, setSelectedAccountId } = useSearchContext();
|
||||||
const { minimalList = [] } = useMinimalAccountList()
|
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 accountIds: number[] = filter.account_ids ?? [];
|
||||||
const selectedMailboxIds: number[] = filter.mailbox_ids ?? []
|
const selectedMailboxIds: number[] = filter.mailbox_ids ?? [];
|
||||||
|
|
||||||
const { mailboxes, isLoading } = useQueries({
|
const [localSelectedIds, setLocalSelectedIds] = React.useState<number[]>([]);
|
||||||
queries: accountIds.map(id => ({
|
const [activeAccountId, setActiveAccountId] = React.useState<number | undefined>(undefined);
|
||||||
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 toggleMailbox = (id: number) => {
|
const queryClient = useQueryClient();
|
||||||
setFilter(prev => {
|
|
||||||
const next = { ...prev }
|
|
||||||
const set = new Set<number>(next.mailbox_ids ?? [])
|
|
||||||
|
|
||||||
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)
|
const currentAccountIds = filter.account_ids ?? [];
|
||||||
|
if (currentAccountIds.length > 0) {
|
||||||
if (ids.length === 0) delete next.mailbox_ids
|
if (!activeAccountId || !currentAccountIds.includes(activeAccountId)) {
|
||||||
else next.mailbox_ids = ids
|
setActiveAccountId(currentAccountIds[0]);
|
||||||
|
}
|
||||||
return next
|
} else {
|
||||||
})
|
setActiveAccountId(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
}, [localOpen, activeAccountId, filter.account_ids, filter.mailbox_ids]);
|
||||||
|
|
||||||
for (const list of map.values()) {
|
const { data: activeMailboxes = [], isLoading: activeIsLoading } = useQuery({
|
||||||
list.sort((a, b) => {
|
queryKey: ['search-mailboxes', activeAccountId],
|
||||||
const aSel = selectedMailboxIds.includes(a.id)
|
queryFn: () => list_mailboxes(activeAccountId!, false),
|
||||||
const bSel = selectedMailboxIds.includes(b.id)
|
enabled: !!activeAccountId,
|
||||||
if (aSel && !bSel) return -1
|
});
|
||||||
if (!aSel && bSel) return 1
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(map.entries())
|
const treeData = React.useMemo(() => {
|
||||||
}, [mailboxes, search, selectedMailboxIds])
|
const filtered = search.trim()
|
||||||
|
? activeMailboxes.filter(m => m.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: activeMailboxes;
|
||||||
|
return buildTree(filtered);
|
||||||
|
}, [activeMailboxes, search]);
|
||||||
|
|
||||||
const defaultOpen = grouped
|
const disabled = accountIds.length === 0;
|
||||||
.filter(([, boxes]) =>
|
|
||||||
boxes.some(m => selectedMailboxIds.includes(m.id))
|
|
||||||
)
|
|
||||||
.map(([id]) => id.toString())
|
|
||||||
|
|
||||||
const getAccountEmail = (id: number) =>
|
const handleApply = () => {
|
||||||
minimalList.find(a => a.id === id)?.email ?? ''
|
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 (
|
return (
|
||||||
<Popover>
|
<Popover open={localOpen} onOpenChange={setLocalOpen} >
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-8 rounded-none px-3 gap-1.5',
|
'h-8 rounded-none px-3 gap-1.5 transition-colors',
|
||||||
selectedMailboxIds.length > 0 &&
|
selectedMailboxIds.length > 0 && 'bg-primary/10 text-primary border-primary/20'
|
||||||
'bg-primary/10 text-primary'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Folders className="h-4 w-4" />
|
<Folders className="h-4 w-4" />
|
||||||
{t('search_mailbox.label')}
|
<span className="max-w-[100px] truncate">{t('search_mailbox.label')}</span>
|
||||||
{selectedMailboxIds.length > 0 && (
|
{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}
|
{selectedMailboxIds.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent align="start" className="min-w-[260px] w-fit max-w-[620px] p-1">
|
<PopoverContent
|
||||||
<div className="p-1 pb-2">
|
align="start"
|
||||||
<Input
|
className="w-[740px] max-w-[95vw] p-0 flex flex-col h-[480px] shadow-xl border-muted"
|
||||||
value={search}
|
>
|
||||||
onChange={e => setSearch(e.target.value)}
|
<div className="flex items-center gap-2 p-2 border-b bg-muted/10">
|
||||||
placeholder={t('search_mailbox.search_placeholder')}
|
<div className="relative flex-1">
|
||||||
className="h-8 text-sm"
|
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
/>
|
<Input
|
||||||
</div>
|
value={search}
|
||||||
{selectedMailboxIds.length > 0 && (
|
onChange={e => setSearch(e.target.value)}
|
||||||
<div className="px-1 pb-2">
|
placeholder={t('search_mailbox.search_placeholder')}
|
||||||
|
className="h-9 pl-8 text-xs bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{localSelectedIds.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={clearAllMailboxes}
|
onClick={() => setLocalSelectedIds([])}
|
||||||
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive transition-colors"
|
className="h-9 text-xs text-destructive hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
<X className="mr-2 h-3.5 w-3.5" />
|
<X className="mr-1.5 h-3 w-3" />
|
||||||
{t('search_mailbox.clear_mailboxes')} ({selectedMailboxIds.length})
|
{t('common.clear')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</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
|
|
||||||
|
|
||||||
return (
|
<div className="flex flex-1 min-h-0">
|
||||||
<AccordionItem
|
<div className="w-64 border-r bg-muted/20 flex flex-col">
|
||||||
key={accountId}
|
<ScrollArea className="flex-1">
|
||||||
value={accountId.toString()}
|
<div className="p-2 space-y-1">
|
||||||
>
|
{accountIds.map(id => {
|
||||||
<AccordionTrigger className="text-xs px-2 py-1.5">
|
const acc = minimalList.find(a => a.id === id);
|
||||||
<span className="truncate">
|
const isActive = activeAccountId === id;
|
||||||
{getAccountEmail(accountId)}
|
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>
|
</span>
|
||||||
|
{count > 0 && (
|
||||||
{selectedCount > 0 && (
|
<span className="text-[10px] font-bold bg-primary/10 px-1.5 py-0.5 rounded-full">
|
||||||
<span className="ml-2 text-[10px] text-primary">
|
{count}
|
||||||
{selectedCount}
|
|
||||||
</span>
|
</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="p-3 border-t bg-muted/10 flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="text-[10px] text-muted-foreground font-medium">
|
||||||
{boxes.map(mailbox => {
|
{t('search_mailbox.selected_total')}: <span className="text-foreground">{localSelectedIds.length}</span>
|
||||||
const checked =
|
</div>
|
||||||
selectedMailboxIds.includes(mailbox.id)
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setLocalOpen(false)} className="h-8 px-3 text-xs">
|
||||||
return (
|
{t('common.cancel')}
|
||||||
<TooltipProvider key={mailbox.id}>
|
</Button>
|
||||||
<Tooltip>
|
<Button size="sm" onClick={handleApply} className="h-8 px-4 text-xs gap-1.5 shadow-sm">
|
||||||
<TooltipTrigger asChild>
|
<Check className="h-3.5 w-3.5" />
|
||||||
<div
|
{t('common.apply')}
|
||||||
onClick={() =>
|
</Button>
|
||||||
toggleMailbox(mailbox.id)
|
</div>
|
||||||
}
|
</div>
|
||||||
className={cn(
|
</div>
|
||||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
</div>
|
||||||
'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>
|
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover >
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { type Table } from '@tanstack/react-table'
|
import { type Table } from '@tanstack/react-table'
|
||||||
import { DataTableViewOptions } from './view-options'
|
import { DataTableViewOptions } from './view-options'
|
||||||
import { TagFilterPopover } from '../tag-filter-popover'
|
import { TagFilterPopover } from '../tag-filter-popover'
|
||||||
import { AccountMailboxFilter } from '../account-mailbox-filter'
|
|
||||||
import { TimePopover } from '../time-popover'
|
import { TimePopover } from '../time-popover'
|
||||||
import { MailFilterPopover } from '../contact-popover'
|
import { MailFilterPopover } from '../contact-popover'
|
||||||
import { TextSearchInput } from '../text-search-input'
|
import { TextSearchInput } from '../text-search-input'
|
||||||
import { MoreFiltersPopover } from '../more-filters-popover'
|
import { MoreFiltersPopover } from '../more-filters-popover'
|
||||||
import { FilterResetButton } from '../filter-reset'
|
import { FilterResetButton } from '../filter-reset'
|
||||||
|
import { MailboxPopover } from '../mailbox-popover'
|
||||||
|
import { AccountPopover } from '../account-popover'
|
||||||
|
|
||||||
type DataTableToolbarProps<TData> = {
|
type DataTableToolbarProps<TData> = {
|
||||||
table: Table<TData>
|
table: Table<TData>
|
||||||
@@ -25,9 +26,10 @@ export function DataTableToolbar<TData>({
|
|||||||
</div>
|
</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 lg:justify-end">
|
||||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap">
|
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap">
|
||||||
<TagFilterPopover />
|
<AccountPopover />
|
||||||
<AccountMailboxFilter />
|
<MailboxPopover />
|
||||||
<MailFilterPopover />
|
<MailFilterPopover />
|
||||||
|
<TagFilterPopover />
|
||||||
<TimePopover />
|
<TimePopover />
|
||||||
<MoreFiltersPopover />
|
<MoreFiltersPopover />
|
||||||
<FilterResetButton />
|
<FilterResetButton />
|
||||||
|
|||||||
@@ -144,8 +144,6 @@ export const TokenCardList: React.FC<Props> = ({ tokens, userId }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Meta */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-muted-foreground">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
<span className="block font-medium text-foreground">
|
<span className="block font-medium text-foreground">
|
||||||
|
|||||||
@@ -46,9 +46,6 @@ const AuthenticatedOauth2IndexLazyImport = createFileRoute(
|
|||||||
const AuthenticatedOauth2ResultIndexLazyImport = createFileRoute(
|
const AuthenticatedOauth2ResultIndexLazyImport = createFileRoute(
|
||||||
'/_authenticated/oauth2-result/',
|
'/_authenticated/oauth2-result/',
|
||||||
)()
|
)()
|
||||||
const AuthenticatedMailboxesIndexLazyImport = createFileRoute(
|
|
||||||
'/_authenticated/mailboxes/',
|
|
||||||
)()
|
|
||||||
const AuthenticatedApiDocsIndexLazyImport = createFileRoute(
|
const AuthenticatedApiDocsIndexLazyImport = createFileRoute(
|
||||||
'/_authenticated/api-docs/',
|
'/_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 =
|
const AuthenticatedApiDocsIndexLazyRoute =
|
||||||
AuthenticatedApiDocsIndexLazyImport.update({
|
AuthenticatedApiDocsIndexLazyImport.update({
|
||||||
id: '/api-docs/',
|
id: '/api-docs/',
|
||||||
@@ -451,13 +439,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedApiDocsIndexLazyImport
|
preLoaderRoute: typeof AuthenticatedApiDocsIndexLazyImport
|
||||||
parentRoute: typeof AuthenticatedRouteImport
|
parentRoute: typeof AuthenticatedRouteImport
|
||||||
}
|
}
|
||||||
'/_authenticated/mailboxes/': {
|
|
||||||
id: '/_authenticated/mailboxes/'
|
|
||||||
path: '/mailboxes'
|
|
||||||
fullPath: '/mailboxes'
|
|
||||||
preLoaderRoute: typeof AuthenticatedMailboxesIndexLazyImport
|
|
||||||
parentRoute: typeof AuthenticatedRouteImport
|
|
||||||
}
|
|
||||||
'/_authenticated/oauth2-result/': {
|
'/_authenticated/oauth2-result/': {
|
||||||
id: '/_authenticated/oauth2-result/'
|
id: '/_authenticated/oauth2-result/'
|
||||||
path: '/oauth2-result'
|
path: '/oauth2-result'
|
||||||
@@ -550,7 +531,6 @@ interface AuthenticatedRouteRouteChildren {
|
|||||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||||
AuthenticatedAccountsIndexLazyRoute: typeof AuthenticatedAccountsIndexLazyRoute
|
AuthenticatedAccountsIndexLazyRoute: typeof AuthenticatedAccountsIndexLazyRoute
|
||||||
AuthenticatedApiDocsIndexLazyRoute: typeof AuthenticatedApiDocsIndexLazyRoute
|
AuthenticatedApiDocsIndexLazyRoute: typeof AuthenticatedApiDocsIndexLazyRoute
|
||||||
AuthenticatedMailboxesIndexLazyRoute: typeof AuthenticatedMailboxesIndexLazyRoute
|
|
||||||
AuthenticatedOauth2ResultIndexLazyRoute: typeof AuthenticatedOauth2ResultIndexLazyRoute
|
AuthenticatedOauth2ResultIndexLazyRoute: typeof AuthenticatedOauth2ResultIndexLazyRoute
|
||||||
AuthenticatedOauth2IndexLazyRoute: typeof AuthenticatedOauth2IndexLazyRoute
|
AuthenticatedOauth2IndexLazyRoute: typeof AuthenticatedOauth2IndexLazyRoute
|
||||||
AuthenticatedSearchIndexLazyRoute: typeof AuthenticatedSearchIndexLazyRoute
|
AuthenticatedSearchIndexLazyRoute: typeof AuthenticatedSearchIndexLazyRoute
|
||||||
@@ -564,7 +544,6 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
|||||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||||
AuthenticatedAccountsIndexLazyRoute: AuthenticatedAccountsIndexLazyRoute,
|
AuthenticatedAccountsIndexLazyRoute: AuthenticatedAccountsIndexLazyRoute,
|
||||||
AuthenticatedApiDocsIndexLazyRoute: AuthenticatedApiDocsIndexLazyRoute,
|
AuthenticatedApiDocsIndexLazyRoute: AuthenticatedApiDocsIndexLazyRoute,
|
||||||
AuthenticatedMailboxesIndexLazyRoute: AuthenticatedMailboxesIndexLazyRoute,
|
|
||||||
AuthenticatedOauth2ResultIndexLazyRoute:
|
AuthenticatedOauth2ResultIndexLazyRoute:
|
||||||
AuthenticatedOauth2ResultIndexLazyRoute,
|
AuthenticatedOauth2ResultIndexLazyRoute,
|
||||||
AuthenticatedOauth2IndexLazyRoute: AuthenticatedOauth2IndexLazyRoute,
|
AuthenticatedOauth2IndexLazyRoute: AuthenticatedOauth2IndexLazyRoute,
|
||||||
@@ -594,7 +573,6 @@ export interface FileRoutesByFullPath {
|
|||||||
'/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
'/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
||||||
'/accounts': typeof AuthenticatedAccountsIndexLazyRoute
|
'/accounts': typeof AuthenticatedAccountsIndexLazyRoute
|
||||||
'/api-docs': typeof AuthenticatedApiDocsIndexLazyRoute
|
'/api-docs': typeof AuthenticatedApiDocsIndexLazyRoute
|
||||||
'/mailboxes': typeof AuthenticatedMailboxesIndexLazyRoute
|
|
||||||
'/oauth2-result': typeof AuthenticatedOauth2ResultIndexLazyRoute
|
'/oauth2-result': typeof AuthenticatedOauth2ResultIndexLazyRoute
|
||||||
'/oauth2': typeof AuthenticatedOauth2IndexLazyRoute
|
'/oauth2': typeof AuthenticatedOauth2IndexLazyRoute
|
||||||
'/search': typeof AuthenticatedSearchIndexLazyRoute
|
'/search': typeof AuthenticatedSearchIndexLazyRoute
|
||||||
@@ -619,7 +597,6 @@ export interface FileRoutesByTo {
|
|||||||
'/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
'/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
||||||
'/accounts': typeof AuthenticatedAccountsIndexLazyRoute
|
'/accounts': typeof AuthenticatedAccountsIndexLazyRoute
|
||||||
'/api-docs': typeof AuthenticatedApiDocsIndexLazyRoute
|
'/api-docs': typeof AuthenticatedApiDocsIndexLazyRoute
|
||||||
'/mailboxes': typeof AuthenticatedMailboxesIndexLazyRoute
|
|
||||||
'/oauth2-result': typeof AuthenticatedOauth2ResultIndexLazyRoute
|
'/oauth2-result': typeof AuthenticatedOauth2ResultIndexLazyRoute
|
||||||
'/oauth2': typeof AuthenticatedOauth2IndexLazyRoute
|
'/oauth2': typeof AuthenticatedOauth2IndexLazyRoute
|
||||||
'/search': typeof AuthenticatedSearchIndexLazyRoute
|
'/search': typeof AuthenticatedSearchIndexLazyRoute
|
||||||
@@ -649,7 +626,6 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
'/_authenticated/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
||||||
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexLazyRoute
|
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexLazyRoute
|
||||||
'/_authenticated/api-docs/': typeof AuthenticatedApiDocsIndexLazyRoute
|
'/_authenticated/api-docs/': typeof AuthenticatedApiDocsIndexLazyRoute
|
||||||
'/_authenticated/mailboxes/': typeof AuthenticatedMailboxesIndexLazyRoute
|
|
||||||
'/_authenticated/oauth2-result/': typeof AuthenticatedOauth2ResultIndexLazyRoute
|
'/_authenticated/oauth2-result/': typeof AuthenticatedOauth2ResultIndexLazyRoute
|
||||||
'/_authenticated/oauth2/': typeof AuthenticatedOauth2IndexLazyRoute
|
'/_authenticated/oauth2/': typeof AuthenticatedOauth2IndexLazyRoute
|
||||||
'/_authenticated/search/': typeof AuthenticatedSearchIndexLazyRoute
|
'/_authenticated/search/': typeof AuthenticatedSearchIndexLazyRoute
|
||||||
@@ -679,7 +655,6 @@ export interface FileRouteTypes {
|
|||||||
| '/users/roles'
|
| '/users/roles'
|
||||||
| '/accounts'
|
| '/accounts'
|
||||||
| '/api-docs'
|
| '/api-docs'
|
||||||
| '/mailboxes'
|
|
||||||
| '/oauth2-result'
|
| '/oauth2-result'
|
||||||
| '/oauth2'
|
| '/oauth2'
|
||||||
| '/search'
|
| '/search'
|
||||||
@@ -703,7 +678,6 @@ export interface FileRouteTypes {
|
|||||||
| '/users/roles'
|
| '/users/roles'
|
||||||
| '/accounts'
|
| '/accounts'
|
||||||
| '/api-docs'
|
| '/api-docs'
|
||||||
| '/mailboxes'
|
|
||||||
| '/oauth2-result'
|
| '/oauth2-result'
|
||||||
| '/oauth2'
|
| '/oauth2'
|
||||||
| '/search'
|
| '/search'
|
||||||
@@ -731,7 +705,6 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated/users/roles'
|
| '/_authenticated/users/roles'
|
||||||
| '/_authenticated/accounts/'
|
| '/_authenticated/accounts/'
|
||||||
| '/_authenticated/api-docs/'
|
| '/_authenticated/api-docs/'
|
||||||
| '/_authenticated/mailboxes/'
|
|
||||||
| '/_authenticated/oauth2-result/'
|
| '/_authenticated/oauth2-result/'
|
||||||
| '/_authenticated/oauth2/'
|
| '/_authenticated/oauth2/'
|
||||||
| '/_authenticated/search/'
|
| '/_authenticated/search/'
|
||||||
@@ -790,7 +763,6 @@ export const routeTree = rootRoute
|
|||||||
"/_authenticated/",
|
"/_authenticated/",
|
||||||
"/_authenticated/accounts/",
|
"/_authenticated/accounts/",
|
||||||
"/_authenticated/api-docs/",
|
"/_authenticated/api-docs/",
|
||||||
"/_authenticated/mailboxes/",
|
|
||||||
"/_authenticated/oauth2-result/",
|
"/_authenticated/oauth2-result/",
|
||||||
"/_authenticated/oauth2/",
|
"/_authenticated/oauth2/",
|
||||||
"/_authenticated/search/"
|
"/_authenticated/search/"
|
||||||
@@ -878,10 +850,6 @@ export const routeTree = rootRoute
|
|||||||
"filePath": "_authenticated/api-docs/index.lazy.tsx",
|
"filePath": "_authenticated/api-docs/index.lazy.tsx",
|
||||||
"parent": "/_authenticated"
|
"parent": "/_authenticated"
|
||||||
},
|
},
|
||||||
"/_authenticated/mailboxes/": {
|
|
||||||
"filePath": "_authenticated/mailboxes/index.lazy.tsx",
|
|
||||||
"parent": "/_authenticated"
|
|
||||||
},
|
|
||||||
"/_authenticated/oauth2-result/": {
|
"/_authenticated/oauth2-result/": {
|
||||||
"filePath": "_authenticated/oauth2-result/index.lazy.tsx",
|
"filePath": "_authenticated/oauth2-result/index.lazy.tsx",
|
||||||
"parent": "/_authenticated"
|
"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