feat(ui): sync search filters with URL and add dashboard navigation

This commit is contained in:
rustmailer
2026-03-18 20:16:01 +08:00
parent d690f57290
commit 2228e98410
7 changed files with 307 additions and 128 deletions
+68 -19
View File
@@ -22,7 +22,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, BarChart, Bar } from 'recharts';
import { Mail, HardDrive, Database, Users, Inbox, Info } from 'lucide-react';
import { Mail, HardDrive, Database, Users, Inbox, Info, Search } from 'lucide-react';
import { formatBytes, formatNumber } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { get_dashboard_stats, INITIAL_DASHBOARD_STATS, TimeBucket } from '@/api/system/api';
@@ -30,6 +30,8 @@ import { Main } from '@/components/layout/main';
import { FixedHeader } from '@/components/layout/fixed-header';
import { useTranslation } from 'react-i18next';
import { getToken } from '@/stores/authStore';
import { useNavigate } from '@tanstack/react-router';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
interface DailyActivity {
date: string;
@@ -111,6 +113,8 @@ export default function MailArchiveDashboard() {
placeholderData: INITIAL_DASHBOARD_STATS,
});
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const currentLocale = i18n.resolvedLanguage || i18n.language || navigator.language;
@@ -123,6 +127,28 @@ export default function MailArchiveDashboard() {
const hasTopEmails = stats?.top_largest_emails && stats.top_largest_emails.length > 0;
const hasTopAccounts = stats?.top_accounts && stats.top_accounts.length > 0;
const { minimalList } = useMinimalAccountList();
const getAccountIdByEmail = (email: string): number | null => {
if (!minimalList) return null;
const account = minimalList.find(a => a.email === email);
return account ? account.id : null;
};
const handleQuickSearch = (filter: Record<string, any>) => {
navigate({
to: '/search',
search: (prev: any) => ({
page: 1,
pageSize: prev.pageSize ?? 50,
sortBy: prev.sortBy ?? "DATE",
sortOrder: prev.sortOrder ?? "desc",
q: JSON.stringify(filter),
}),
});
};
const attachmentData = totalAttachments > 0
? [
{ name: 'With Attachments', value: attachmentRatio, fill: COLORS[1] },
@@ -380,9 +406,14 @@ export default function MailArchiveDashboard() {
</TableHeader>
<TableBody>
{stats!.top_senders.map((s) => (
<TableRow key={s.key}>
<TableCell className="font-medium max-w-[180px] truncate" title={s.key}>
{s.key}
<TableRow
key={s.key}
className="cursor-pointer hover:bg-accent/50 group"
onClick={() => handleQuickSearch({ from: s.key })}
>
<TableCell className="font-medium max-w-[380px] truncate flex items-center gap-2">
<Search size={12} className="opacity-0 group-hover:opacity-100 text-primary transition-opacity" />
<span title={s.key}>{s.key}</span>
</TableCell>
<TableCell className="text-right">{formatNumber(s.count)}</TableCell>
</TableRow>
@@ -410,14 +441,18 @@ export default function MailArchiveDashboard() {
</TableHeader>
<TableBody>
{stats!.top_largest_emails.map((m, index) => (
<TableRow key={index}>
<TableCell
className="max-w-[180px] truncate font-medium"
title={m.subject}
>
{m.subject || t('dashboard.noSubject')}
<TableRow
key={index}
className="cursor-pointer hover:bg-accent/50 group"
onClick={() => handleQuickSearch({ text: m.subject })}
>
<TableCell className="max-w-[350px] truncate font-medium flex items-center gap-2" title={m.subject}>
<Search size={12} className="opacity-0 group-hover:opacity-100 text-primary transition-opacity" />
<span>{m.subject || t('dashboard.noSubject')}</span>
</TableCell>
<TableCell className="text-right font-mono text-orange-600">
{formatBytes(m.size_bytes)}
</TableCell>
<TableCell className="text-right">{formatBytes(m.size_bytes)}</TableCell>
</TableRow>
))}
</TableBody>
@@ -442,14 +477,28 @@ export default function MailArchiveDashboard() {
</TableRow>
</TableHeader>
<TableBody>
{stats!.top_accounts.map((acc) => (
<TableRow key={acc.key}>
<TableCell className="font-medium max-w-[160px] truncate" title={acc.key}>
{acc.key}
</TableCell>
<TableCell className="text-right">{formatNumber(acc.count)}</TableCell>
</TableRow>
))}
{stats!.top_accounts.map((acc) => {
const accountId = getAccountIdByEmail(acc.key);
return (
<TableRow
key={acc.key}
className="group cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => {
if (accountId) {
handleQuickSearch({ account_ids: [accountId] });
} else {
handleQuickSearch({ to: acc.key });
}
}}
>
<TableCell className="font-medium max-w-[300px] truncate flex items-center gap-2">
<Search size={12} className="opacity-0 group-hover:opacity-100 text-primary transition-opacity" />
<span title={acc.key}>{acc.key}</span>
</TableCell>
<TableCell className="text-right">{formatNumber(acc.count)}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
) : (
+131 -56
View File
@@ -19,7 +19,7 @@
import { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { format, formatDistanceToNow } from "date-fns"
import { MessageSquareText, Paperclip } from "lucide-react"
import { MessageSquareText, Paperclip, Search } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Checkbox } from "@/components/ui/checkbox"
import { EmailEnvelope } from "@/api"
@@ -32,9 +32,10 @@ import LongText from "@/components/long-text"
import { DataTableColumnHeader } from "./table/data-table-column-header"
import { SearchTable } from "./table/table"
import { DataTableRowActions } from "./table/data-table-row-actions"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DataTableToolbar } from "./table/toolbar"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { useSearchMessages } from "@/hooks/use-search-messages"
interface MailListProps {
items: EmailEnvelope[]
@@ -87,87 +88,161 @@ export function MailListTable({
{
accessorKey: "account_email",
header: t('search.account'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.account_email}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 156,
cell: ({ row }) => {
const { setFilter } = useSearchMessages();
const { account_email, account_id } = row.original;
return (
<div className="group relative flex items-center w-full min-w-0 h-full">
<button
onClick={(e) => {
e.stopPropagation();
setFilter((prev: any) => ({
...prev,
account_ids: [account_id],
mailbox_ids: undefined
}));
}}
className="absolute left-0 z-10 opacity-0 group-hover:opacity-100 p-0.5 bg-background border rounded shadow-sm hover:bg-accent"
>
<Search size={12} className="text-primary" />
</button>
<span className="text-[11px] truncate group-hover:pl-5 transition-all duration-200">
{account_email}
</span>
</div>
);
},
meta: { className: 'w-[150px]' },
minSize: 150, maxSize: 150,
},
{
accessorKey: "mailbox_name",
header: t('search.mailbox'),
cell: ({ row }) => {
const mailbox = row.original.mailbox_name
const tags = row.original.tags ?? []
const { setFilter } = useSearchMessages();
const { mailbox_name, mailbox_id, account_id, tags } = row.original;
if (!mailbox) return null
const visible = tags.slice(0, 3)
const rest = tags.length - visible.length
const fullTags = tags.join(' · ')
if (!mailbox_name) return null;
const safeTags = tags ?? [];
const visibleTags = safeTags.slice(0, 2);
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex flex-col leading-tight max-w-[130px] cursor-default">
<span className="text-xs truncate">
{mailbox}
</span>
{visible.length > 0 && (
<span className="text-[10px] text-primary/80 truncate">
{visible.join(' · ')}
{rest > 0 && ` · +${rest}`}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-xs"
>
<div className="text-xs font-medium mb-1">
{mailbox}
</div>
<div className="text-[11px] text-muted-foreground break-words">
{fullTags}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
<div className="group relative flex items-center w-full min-w-0 h-full">
<button
onClick={(e) => {
e.stopPropagation();
setFilter((prev: any) => ({
...prev,
account_ids: [account_id],
mailbox_ids: [mailbox_id]
}));
}}
className="absolute left-0 z-10 opacity-0 group-hover:opacity-100 p-0.5 bg-background border rounded shadow-sm hover:bg-accent transition-all"
>
<Search size={11} className="text-primary" />
</button>
<div className="flex flex-col min-w-0 transition-all duration-200 group-hover:pl-5">
<span className="text-[11px] truncate font-medium leading-none">
{mailbox_name}
</span>
{safeTags.length > 0 && (
<span className="text-[9px] text-primary/70 truncate leading-none mt-0.5">
{visibleTags.join(' · ')}
{safeTags.length > 2 && ` · +${safeTags.length - 2}`}
</span>
)}
</div>
</div>
);
},
meta: { className: 'text-left text-xs' },
minSize: 116,
maxSize: 116,
maxSize: 120,
},
{
accessorKey: "from",
header: t('search.from'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.from}</LongText>,
cell: ({ row }) => {
const fromEmail = row.original.from;
const { setFilter } = useSearchMessages();
return (
<div className="group relative flex items-center w-full min-w-0">
<button
onClick={(e) => {
e.stopPropagation();
setFilter((prev: Record<string, any>) => ({ ...prev, from: fromEmail }));
}}
className="absolute left-0 z-10 opacity-0 group-hover:opacity-100 p-1 bg-background/90 hover:bg-accent rounded shadow-sm transition-all cursor-pointer"
title={`Filter by ${fromEmail}`}
>
<Search size={12} className="text-primary" />
</button>
<LongText
className='text-xs pl-0 group-hover:pl-6 transition-all duration-200 ease-in-out'
>
{fromEmail}
</LongText>
</div>
);
},
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 156,
maxSize: 300,
},
{
accessorKey: "to",
header: t('search.to'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.to.join(", ")}</LongText>,
cell: ({ row }) => {
const recipients = row.original.to || [];
const { setFilter } = useSearchMessages();
return (
<div className="group relative flex items-center w-full min-w-0">
{recipients.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setFilter((prev: Record<string, any>) => ({ ...prev, to: recipients[0] }));
}}
className="absolute left-0 z-10 opacity-0 group-hover:opacity-100 p-1 bg-background/90 hover:bg-accent rounded shadow-sm transition-all cursor-pointer"
title={`${t('search.filter_by')} ${recipients[0]}`}
>
<Search size={12} className="text-primary" />
</button>
)}
<div className="text-xs transition-all duration-200 ease-in-out group-hover:pl-6 flex flex-wrap gap-x-1 min-w-0 overflow-hidden">
{recipients.map((email, index) => (
<span key={index} className="flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setFilter((prev: Record<string, any>) => ({ ...prev, to: email }));
}}
className="hover:text-primary hover:underline transition-colors truncate max-w-[150px]"
title={`${t('search.filter_by')} ${email}`}
>
{email}
</button>
{index < recipients.length - 1 && (
<span className="text-muted-foreground ml-0.5">,</span>
)}
</span>
))}
</div>
</div>
);
},
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 156,
maxSize: 200,
},
{
accessorKey: "subject",
header: t('search.subject'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 450,
maxSize: 456,
maxSize: 600,
},
{
id: "text_preview",
+1 -2
View File
@@ -163,7 +163,7 @@ export function MailboxPopover() {
const { data: activeMailboxes = [], isLoading: activeIsLoading } = useQuery({
queryKey: ['search-mailboxes', activeAccountId],
queryFn: () => list_mailboxes(activeAccountId!, false),
enabled: !!activeAccountId,
enabled: !!activeAccountId,
});
const treeData = React.useMemo(() => {
@@ -184,7 +184,6 @@ export function MailboxPopover() {
};
const handleDeleteClick = (id: string) => {
console.log("delete=", id);
setDeleteMailboxId(id);
setSelectedAccountId(activeAccountId);
setOpen('delete-mailbox');