mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
chore: support bulk restore emails
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { X, Trash2 } from 'lucide-react'
|
||||
import { X, Trash2, Upload } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -37,7 +37,7 @@ type MailBulkActionsProps = {
|
||||
}
|
||||
|
||||
export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
const { selected, setSelected, setOpen, setToDelete } = useSearchContext()
|
||||
const { selected, setSelected, setOpen, setToDelete, setCurrentEnvelope } = useSearchContext()
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -60,6 +60,12 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
setOpen('delete')
|
||||
}
|
||||
|
||||
|
||||
const handleRestore = () => {
|
||||
setCurrentEnvelope(undefined);
|
||||
setOpen('restore')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const buttons = toolbarRef.current?.querySelectorAll('button')
|
||||
if (!buttons || buttons.length === 0) return
|
||||
@@ -161,7 +167,26 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRestore}
|
||||
className="gap-1"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('search.bulkActions.restoreDesc')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
{/* Delete */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -95,14 +95,6 @@ export default function Search() {
|
||||
>
|
||||
<div className="mx-auto w-full px-4">
|
||||
<div className="flex gap-6">
|
||||
{/* <aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<EnvelopeTags
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</div>
|
||||
</aside> */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{isLoading && (
|
||||
<Card>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio } from 'lucide-react';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -119,7 +119,7 @@ export function MailMessageView({
|
||||
showHeader = true
|
||||
}: MailMessageViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setToDelete, setOpen } = useSearchContext();
|
||||
const { setToDelete, setOpen, setSelected } = useSearchContext();
|
||||
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
|
||||
@@ -280,6 +280,22 @@ export function MailMessageView({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mail.viewThread')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setSelected(new Map())
|
||||
setOpen('restore')
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('restore_message.restore_to_imap', 'Restore Mail')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,52 @@ import { AxiosError } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useSearchContext } from './context'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
|
||||
function MessageSummary({ envelope, t }: { envelope: EmailEnvelope, t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="mt-3 rounded-md border bg-muted/20 p-3 text-sm overflow-hidden">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1.5">
|
||||
|
||||
<span className="font-medium text-muted-foreground">{t("mail.subject")}:</span>
|
||||
<div className="break-words font-medium">
|
||||
{envelope.subject || <em className="italic opacity-70">(No subject)</em>}
|
||||
</div>
|
||||
|
||||
<span className="font-medium text-muted-foreground">{t("mail.from")}:</span>
|
||||
<div className="break-all text-foreground/90">
|
||||
{envelope.from}
|
||||
</div>
|
||||
|
||||
{envelope.to?.length > 0 && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">{t("mail.to")}:</span>
|
||||
<div className="break-all text-foreground/90">
|
||||
{envelope.to.slice(0, 2).join(", ")}
|
||||
{envelope.to.length > 2 && " …"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="font-medium text-muted-foreground">{t("mail.date")}:</span>
|
||||
<div className="text-foreground/90">
|
||||
{new Date(envelope.date).toLocaleString()}
|
||||
</div>
|
||||
|
||||
{envelope.mailbox_name && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">{t("search.mailbox")}:</span>
|
||||
<div className="truncate text-foreground/90" title={envelope.mailbox_name}>
|
||||
{envelope.mailbox_name}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface RestoreMessageDialogProps {
|
||||
open: boolean
|
||||
@@ -36,12 +82,26 @@ export function RestoreMessageDialog({
|
||||
onOpenChange
|
||||
}: RestoreMessageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentEnvelope } = useSearchContext()
|
||||
const { currentEnvelope, selected } = useSearchContext()
|
||||
|
||||
const accountsWithSelection = Array.from(selected.entries()).filter(([_, ids]) => ids.size > 0);
|
||||
const selectedCount = accountsWithSelection.reduce((sum, [_, set]) => sum + set.size, 0);
|
||||
const accountCount = accountsWithSelection.length;
|
||||
|
||||
const isBulk = selectedCount > 0;
|
||||
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
restore_message(currentEnvelope!.account_id, [currentEnvelope!.id]),
|
||||
mutationFn: async () => {
|
||||
if (isBulk) {
|
||||
const promises = accountsWithSelection.map(([accountId, ids]) =>
|
||||
restore_message(accountId, Array.from(ids))
|
||||
);
|
||||
return Promise.all(promises);
|
||||
} else if (currentEnvelope) {
|
||||
return restore_message(currentEnvelope.account_id, [currentEnvelope.id]);
|
||||
}
|
||||
},
|
||||
onSuccess: handleRestoreSuccess,
|
||||
onError: handleRestoreError,
|
||||
});
|
||||
@@ -90,16 +150,37 @@ export function RestoreMessageDialog({
|
||||
<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.'
|
||||
)}
|
||||
title={isBulk ? t('restore_message.bulkTitle', 'Restore multiple messages') : t('restore_message.title', 'Restore message')}
|
||||
desc={<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'restore_message.desc',
|
||||
'This action will append the selected messages to their corresponding mailboxes on the IMAP server.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{isBulk ? (
|
||||
<div className="rounded-md bg-primary/5 border border-primary/20 p-3 text-sm">
|
||||
<div className="flex justify-between items-center text-primary font-medium">
|
||||
<span>{t('restore_message.summary', 'Summary')}</span>
|
||||
<span className="bg-primary/10 px-2 py-0.5 rounded text-xs">
|
||||
{selectedCount} {t('restore_message.messages', 'messages')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs space-y-1 text-muted-foreground">
|
||||
<p>• {t('restore_message.accountsInvolved', 'Accounts involved')}: {accountCount}</p>
|
||||
<p>• {t('restore_message.bulkWarning', 'Messages will be restored to their original folders.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
currentEnvelope && <MessageSummary envelope={currentEnvelope} t={t} />
|
||||
)}
|
||||
</div>}
|
||||
confirmText={t('restore_message.confirm', 'Restore')}
|
||||
handleConfirm={() => restoreMutation.mutate()}
|
||||
className="sm:max-w-sm"
|
||||
isLoading={restoreMutation.isPending}
|
||||
disabled={restoreMutation.isPending}
|
||||
disabled={restoreMutation.isPending || (!isBulk && !currentEnvelope)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MoreVertical, TagIcon, Trash2 } from 'lucide-react'
|
||||
import { MoreVertical, TagIcon, Trash2, Upload } from 'lucide-react'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { useSearchContext } from '../context'
|
||||
|
||||
@@ -37,7 +37,7 @@ interface DataTableRowActionsProps {
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentEnvelope, setToDelete } = useSearchContext()
|
||||
const { setOpen, setCurrentEnvelope, setSelected, setToDelete } = useSearchContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const toggleToDelete = (accountId: number, mailId: number) => {
|
||||
@@ -93,12 +93,13 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCurrentEnvelope(row.original)
|
||||
setSelected(new Map())
|
||||
setOpen("restore")
|
||||
}}
|
||||
>
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
<DropdownMenuShortcut>
|
||||
<TagIcon size={16} />
|
||||
<Upload size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
Reference in New Issue
Block a user