mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
update
This commit is contained in:
@@ -53,6 +53,7 @@ export interface DashboardStats {
|
||||
with_attachment_count: number; // Emails with attachments
|
||||
without_attachment_count: number; // Emails without attachments
|
||||
top_largest_emails: LargestEmail[]; // Top 10 largest emails
|
||||
top_largest_attachments: LargestAttachment[]; // Top 10 largest attachments
|
||||
system_version: string, //The semantic version string of the currently running backend service
|
||||
commit_hash: string //Git commit hash used to build this system version
|
||||
}
|
||||
@@ -69,6 +70,7 @@ export const INITIAL_DASHBOARD_STATS: DashboardStats = {
|
||||
with_attachment_count: 0,
|
||||
without_attachment_count: 0,
|
||||
top_largest_emails: [],
|
||||
top_largest_attachments: [],
|
||||
system_version: '0.0.0',
|
||||
commit_hash: 'n/a'
|
||||
};
|
||||
@@ -87,6 +89,13 @@ export interface Group {
|
||||
export interface LargestEmail {
|
||||
subject: string; // Email subject
|
||||
size_bytes: number; // Email size in bytes
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface LargestAttachment {
|
||||
name: string; // Attachment name
|
||||
size_bytes: number; // Email size in bytes
|
||||
id: String // attachment id
|
||||
}
|
||||
|
||||
export interface Proxy {
|
||||
|
||||
@@ -55,13 +55,14 @@ interface Props {
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
// 保持颜色逻辑,但在暗色模式下这些颜色也相对友好,如果需要完全适配可调整为 bg-primary/10 等
|
||||
const map: Record<string, string> = {
|
||||
Running: 'bg-blue-100 text-blue-700',
|
||||
Downloading: 'bg-blue-100 text-blue-700',
|
||||
Success: 'bg-green-100 text-green-700',
|
||||
Failed: 'bg-red-100 text-red-700',
|
||||
Cancelled: 'bg-gray-100 text-gray-700',
|
||||
Pending: 'bg-amber-100 text-amber-700',
|
||||
Running: 'bg-blue-500/10 text-blue-600',
|
||||
Downloading: 'bg-blue-500/10 text-blue-600',
|
||||
Success: 'bg-green-500/10 text-green-600',
|
||||
Failed: 'bg-red-500/10 text-red-600',
|
||||
Cancelled: 'bg-muted text-muted-foreground',
|
||||
Pending: 'bg-amber-500/10 text-amber-600',
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className={`${map[status] || ''} border-none font-medium text-[11px] px-1.5 h-5 shrink-0`}>
|
||||
@@ -73,37 +74,36 @@ function StatusBadge({ status }: { status: string }) {
|
||||
function TriggerBadge({ trigger }: { trigger: string }) {
|
||||
const isScheduled = trigger === 'Scheduled'
|
||||
return (
|
||||
<Badge variant="secondary" className={`${isScheduled ? 'bg-purple-50 text-purple-700 border-purple-100' : 'bg-orange-50 text-orange-700 border-orange-100'} font-normal text-xs shrink-0`}>
|
||||
<Badge variant="secondary" className={`${isScheduled ? 'bg-purple-500/10 text-purple-600' : 'bg-orange-500/10 text-orange-600'} font-normal text-xs shrink-0 border-none`}>
|
||||
{trigger}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function FolderDetailItem({ f, t }: { f: FolderProgress, t: (key: string) => string }) {
|
||||
const percentage = Math.min(Math.round((f.current / f.planned) * 100), 100) || 0;
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl bg-card overflow-hidden mb-3 shadow-sm border-slate-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center p-4 gap-3 sm:gap-4 bg-white">
|
||||
<div className="border rounded-xl bg-card overflow-hidden mb-3 shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center p-4 gap-3 sm:gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<LongText className="text-xs font-bold text-slate-900 tracking-tight">
|
||||
<LongText className="text-xs font-bold text-foreground tracking-tight">
|
||||
{f.folder_name}
|
||||
</LongText>
|
||||
</div>
|
||||
<div className="flex items-center justify-between sm:justify-end gap-4">
|
||||
<div className="flex items-center gap-2 flex-1 sm:flex-initial">
|
||||
<div className="flex-1 sm:w-40 lg:w-48 bg-slate-100 rounded-full h-1.5 overflow-hidden">
|
||||
<div className="flex-1 sm:w-40 lg:w-48 bg-secondary rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500 bg-gradient-to-r from-blue-400 to-indigo-500"
|
||||
className="h-full rounded-full transition-all duration-500 bg-primary"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-slate-400 w-7 shrink-0 text-right">{percentage}%</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground w-7 shrink-0 text-right">{percentage}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div className="w-20 sm:w-24 text-right font-mono text-xs sm:text-sm text-slate-500">
|
||||
<span className="font-bold text-slate-900">{f.current}</span>
|
||||
<div className="w-20 sm:w-24 text-right font-mono text-xs sm:text-sm text-muted-foreground">
|
||||
<span className="font-bold text-foreground">{f.current}</span>
|
||||
<span className="mx-0.5 opacity-50">/</span>
|
||||
{f.planned}
|
||||
</div>
|
||||
@@ -116,11 +116,11 @@ function FolderDetailItem({ f, t }: { f: FolderProgress, t: (key: string) => str
|
||||
|
||||
{f.message && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="bg-slate-50 border border-slate-100 rounded-lg p-3 flex gap-3 items-start">
|
||||
<Info className="w-4 h-4 text-slate-400 mt-0.5 shrink-0" />
|
||||
<div className="bg-muted/50 border rounded-lg p-3 flex gap-3 items-start">
|
||||
<Info className="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] font-bold text-slate-900">{t('accounts.runningState.message')}:</p>
|
||||
<p className="text-[10px] font-medium text-slate-600 leading-relaxed">{f.message}</p>
|
||||
<p className="text-[10px] font-bold text-foreground">{t('accounts.runningState.message')}:</p>
|
||||
<p className="text-[10px] font-medium text-muted-foreground leading-relaxed">{f.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,7 +148,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<DialogContent className="max-w-5xl w-[95vw] sm:w-full p-0 flex flex-col h-[90vh] sm:h-[85vh] overflow-hidden gap-0 rounded-t-2xl sm:rounded-xl">
|
||||
<DialogHeader className="px-4 py-3 sm:px-6 sm:py-4 border-b bg-muted/20 shrink-0">
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle className="text-base sm:text-xl font-bold flex items-center gap-2 text-blue-600 truncate">
|
||||
<DialogTitle className="text-base sm:text-xl font-bold flex items-center gap-2 text-primary truncate">
|
||||
<Activity className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
|
||||
<span className="truncate">{currentRow.email}</span>
|
||||
</DialogTitle>
|
||||
@@ -159,27 +159,27 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="active" className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 sm:px-6 border-b bg-white shrink-0 overflow-x-auto no-scrollbar">
|
||||
<div className="px-4 sm:px-6 border-b bg-card shrink-0 overflow-x-auto no-scrollbar">
|
||||
<TabsList className="h-12 w-full justify-start bg-transparent p-0 gap-6 sm:gap-8 flex-nowrap">
|
||||
<TabsTrigger value="active" className="whitespace-nowrap data-[state=active]:border-b-2 data-[state=active]:border-blue-600 rounded-none h-full bg-transparent shadow-none px-0 text-xs sm:text-sm font-bold">
|
||||
<TabsTrigger value="active" className="whitespace-nowrap data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full bg-transparent shadow-none px-0 text-xs sm:text-sm font-bold">
|
||||
{t('accounts.runningState.tabs.active_session')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="whitespace-nowrap data-[state=active]:border-b-2 data-[state=active]:border-blue-600 rounded-none h-full bg-transparent shadow-none px-0 text-xs sm:text-sm font-bold">
|
||||
<TabsTrigger value="history" className="whitespace-nowrap data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full bg-transparent shadow-none px-0 text-xs sm:text-sm font-bold">
|
||||
{t('accounts.runningState.tabs.history')}
|
||||
<Badge variant="secondary" className="ml-2 h-4 px-1 text-[10px] font-bold">{history.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="whitespace-nowrap data-[state=active]:border-b-2 data-[state=active]:border-blue-600 rounded-none h-full bg-transparent shadow-none px-0 text-xs sm:text-sm font-bold">
|
||||
<TabsTrigger value="errors" className="whitespace-nowrap data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full bg-transparent shadow-none px-0 text-xs sm:text-sm font-bold">
|
||||
{t('accounts.runningState.tabs.global_errors')}
|
||||
{globalErrors.length > 0 && <Badge variant="destructive" className="ml-2 h-4 px-1 text-[10px] font-bold">{globalErrors.length}</Badge>}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-slate-50/50 min-h-0 overflow-hidden relative">
|
||||
<div className="flex-1 bg-background min-h-0 overflow-hidden relative">
|
||||
{isLoading ? (
|
||||
<div className="h-full flex flex-col items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mb-2" />
|
||||
<p className="text-sm text-slate-500 font-medium italic">{t('accounts.runningState.loading.fetching_account_state')}</p>
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mb-2" />
|
||||
<p className="text-sm text-muted-foreground font-medium italic">{t('accounts.runningState.loading.fetching_account_state')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -194,29 +194,29 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-2">
|
||||
<div className="p-3 sm:p-4 rounded-xl border bg-white shadow-sm flex items-center justify-between sm:block">
|
||||
<div className="p-3 sm:p-4 rounded-xl border bg-card shadow-sm flex items-center justify-between sm:block">
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase mb-1">{t('accounts.runningState.session.status')}</p>
|
||||
<StatusBadge status={session.status} />
|
||||
</div>
|
||||
<div className="p-3 sm:p-4 rounded-xl border bg-white shadow-sm flex items-center justify-between sm:block">
|
||||
<div className="p-3 sm:p-4 rounded-xl border bg-card shadow-sm flex items-center justify-between sm:block">
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase mb-1">{t('accounts.runningState.session.trigger')}</p>
|
||||
<TriggerBadge trigger={session.trigger} />
|
||||
</div>
|
||||
<div className="p-3 sm:p-4 rounded-xl border bg-white shadow-sm flex items-center justify-between sm:block">
|
||||
<div className="p-3 sm:p-4 rounded-xl border bg-card shadow-sm flex items-center justify-between sm:block">
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase mb-1">{t('accounts.runningState.session.started_at')}</p>
|
||||
<div className="text-sm font-bold font-mono">
|
||||
<div className="text-sm font-bold font-mono text-foreground">
|
||||
{format(new Date(session.start_time), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue="folders" className="w-full">
|
||||
<TabsList className="bg-slate-100 mb-3 h-8">
|
||||
<TabsList className="bg-muted mb-3 h-8">
|
||||
<TabsTrigger value="folders" className="text-[11px] font-bold">
|
||||
{t('accounts.runningState.tabs.folders')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="errors"
|
||||
className="text-[11px] font-bold text-red-600"
|
||||
className="text-[11px] font-bold text-destructive"
|
||||
>
|
||||
{t('accounts.runningState.tabs.errors')} ({session.errors?.length || 0})
|
||||
</TabsTrigger>
|
||||
@@ -228,12 +228,12 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</TabsContent>
|
||||
<TabsContent value="errors">
|
||||
{(!session.errors || session.errors.length === 0) ? (
|
||||
<div className="text-center py-10 text-slate-400 italic text-xs">
|
||||
<div className="text-center py-10 text-muted-foreground italic text-xs">
|
||||
{t('accounts.runningState.empty.no_errors_current')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute left-[14px] top-1 bottom-1 w-0.5 bg-red-100" />
|
||||
<div className="absolute left-[14px] top-1 bottom-1 w-0.5 bg-destructive/20" />
|
||||
<div className="space-y-4">
|
||||
{[...session.errors]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
@@ -242,38 +242,38 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<div className="absolute left-0 top-2 w-[28px] flex justify-center">
|
||||
{ei === 0 ? (
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-600" />
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-destructive" />
|
||||
</span>
|
||||
) : (
|
||||
<div className="w-2 h-2 rounded-full bg-red-200 border border-white" />
|
||||
<div className="w-2 h-2 rounded-full bg-destructive/20" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
p-3 border rounded-lg flex flex-col gap-2 min-w-0
|
||||
${ei === 0
|
||||
? 'bg-red-50 border-red-200 ring-1 ring-red-100'
|
||||
: 'bg-white border-red-100'}
|
||||
? 'bg-destructive/10 border-destructive/20'
|
||||
: 'bg-card border-destructive/10'}
|
||||
`}
|
||||
>
|
||||
<div className="flex justify-between items-start gap-2 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
{ei === 0 && (
|
||||
<Badge className="bg-red-600 text-[9px] h-4 px-1">
|
||||
<Badge className="bg-destructive text-[9px] h-4 px-1">
|
||||
{t('accounts.runningState.latest')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[10px] font-mono text-red-400 font-bold break-all">
|
||||
<span className="text-[10px] font-mono text-destructive font-bold break-all">
|
||||
{format(new Date(err.at), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</span>
|
||||
</div>
|
||||
<AlertTriangle
|
||||
className={`w-4 h-4 shrink-0 ${ei === 0 ? 'text-red-600' : 'text-red-300'
|
||||
className={`w-4 h-4 shrink-0 ${ei === 0 ? 'text-destructive' : 'text-destructive/50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-red-950 whitespace-pre-wrap break-all">
|
||||
<p className="text-xs font-bold text-foreground whitespace-pre-wrap break-all">
|
||||
{err.error}
|
||||
</p>
|
||||
</div>
|
||||
@@ -293,21 +293,21 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 sm:p-6">
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-20 text-slate-400 italic text-sm">{t('accounts.runningState.empty.no_history')}</div>
|
||||
<div className="text-center py-20 text-muted-foreground italic text-sm">{t('accounts.runningState.empty.no_history')}</div>
|
||||
) : (
|
||||
<Accordion type="single" collapsible className="space-y-3">
|
||||
{[...history].reverse().map((h, i) => (
|
||||
<AccordionItem key={i} value={`history-${i}`} className="border rounded-xl bg-white shadow-sm px-4 border-slate-200 overflow-hidden">
|
||||
<AccordionItem key={i} value={`history-${i}`} className="border rounded-xl bg-card shadow-sm px-4 border-border overflow-hidden">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between w-full pr-4 gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xs sm:text-xs font-bold font-mono text-slate-700">
|
||||
<div className="text-xs sm:text-xs font-bold font-mono text-foreground">
|
||||
{format(new Date(h.start_time), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</div>
|
||||
<StatusBadge status={h.status} />
|
||||
<div className="hidden xs:block"><TriggerBadge trigger={h.trigger} /></div>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-slate-400 bg-slate-50 px-2 py-0.5 rounded-full self-start sm:self-auto">
|
||||
<span className="text-[10px] font-bold text-muted-foreground bg-muted px-2 py-0.5 rounded-full self-start sm:self-auto">
|
||||
{Object.keys(h.folder_details).length} {t('accounts.runningState.folders')}
|
||||
<span className="mx-1 opacity-30">·</span>
|
||||
{Object.values(h.folder_details).reduce((sum, f) => sum + (f.current || 0), 0)}
|
||||
@@ -316,11 +316,11 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4 border-t pt-4 mt-1">
|
||||
<AccordionContent className="pb-4 border-t pt-4 mt-1 border-border">
|
||||
<Tabs defaultValue="h-folders" className="w-full">
|
||||
<TabsList className="bg-slate-100 mb-4 h-8">
|
||||
<TabsList className="bg-muted mb-4 h-8">
|
||||
<TabsTrigger value="h-folders" className="text-[11px] font-bold">{t('accounts.runningState.tabs.folders')}</TabsTrigger>
|
||||
<TabsTrigger value="h-errors" className="text-[11px] font-bold text-red-600">
|
||||
<TabsTrigger value="h-errors" className="text-[11px] font-bold text-destructive">
|
||||
{t('accounts.runningState.tabs.errors')} ({h.errors?.length || 0})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -331,12 +331,12 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</TabsContent>
|
||||
<TabsContent value="h-errors">
|
||||
{(!h.errors || h.errors.length === 0) ? (
|
||||
<div className="text-center py-10 text-slate-400 italic text-xs">
|
||||
<div className="text-center py-10 text-muted-foreground italic text-xs">
|
||||
{t('accounts.runningState.empty.no_errors_session')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute left-[14px] top-1 bottom-1 w-0.5 bg-red-100" />
|
||||
<div className="absolute left-[14px] top-1 bottom-1 w-0.5 bg-destructive/20" />
|
||||
<div className="space-y-4">
|
||||
{[...h.errors]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
@@ -345,38 +345,38 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<div className="absolute left-0 top-2 w-[28px] flex justify-center">
|
||||
{ei === 0 ? (
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-600"></span>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-destructive"></span>
|
||||
</span>
|
||||
) : (
|
||||
<div className="w-2 h-2 rounded-full bg-red-200 border border-white" />
|
||||
<div className="w-2 h-2 rounded-full bg-destructive/20" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
p-3 border rounded-lg flex flex-col gap-2 min-w-0
|
||||
${ei === 0
|
||||
? 'bg-red-50 border-red-200 ring-1 ring-red-100'
|
||||
: 'bg-white border-red-100'}
|
||||
`}
|
||||
p-3 border rounded-lg flex flex-col gap-2 min-w-0
|
||||
${ei === 0
|
||||
? 'bg-destructive/10 border-destructive/20'
|
||||
: 'bg-card border-destructive/10'}
|
||||
`}
|
||||
>
|
||||
<div className="flex justify-between items-start gap-2 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
{ei === 0 && (
|
||||
<Badge className="bg-red-600 text-[9px] h-4 px-1">
|
||||
<Badge className="bg-destructive text-[9px] h-4 px-1">
|
||||
{t('accounts.runningState.latest')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[10px] font-mono text-red-400 font-bold break-all">
|
||||
<span className="text-[10px] font-mono text-destructive font-bold break-all">
|
||||
{format(new Date(err.at), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</span>
|
||||
</div>
|
||||
<AlertTriangle
|
||||
className={`w-4 h-4 shrink-0 ${ei === 0 ? 'text-red-600' : 'text-red-300'
|
||||
className={`w-4 h-4 shrink-0 ${ei === 0 ? 'text-destructive' : 'text-destructive/50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs font-bold whitespace-pre-wrap break-all text-red-950">
|
||||
<p className="text-xs font-bold text-foreground whitespace-pre-wrap break-all">
|
||||
{err.error}
|
||||
</p>
|
||||
</div>
|
||||
@@ -399,13 +399,13 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 sm:p-6">
|
||||
{globalErrors.length === 0 ? (
|
||||
<div className="py-32 text-center text-slate-300">
|
||||
<div className="py-32 text-center text-muted-foreground">
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-2 opacity-20" />
|
||||
<p className="font-medium italic text-sm">{t('accounts.runningState.empty.no_global_errors')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute left-[19px] top-2 bottom-2 w-0.5 bg-red-100" />
|
||||
<div className="absolute left-[19px] top-2 bottom-2 w-0.5 bg-destructive/20" />
|
||||
<div className="space-y-6">
|
||||
{[...globalErrors]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
@@ -414,30 +414,30 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<div className="absolute left-0 top-1.5 w-[40px] flex justify-center">
|
||||
{i === 0 ? (
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-600"></span>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-destructive"></span>
|
||||
</span>
|
||||
) : (
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-200 border-2 border-white mt-0.5" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-destructive/20 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
p-4 border rounded-2xl shadow-sm transition-all min-w-0
|
||||
${i === 0
|
||||
? 'border-red-200 bg-red-50/80 ring-1 ring-red-100'
|
||||
: 'border-slate-100 bg-white opacity-80'}
|
||||
? 'border-destructive/20 bg-destructive/5 ring-1 ring-destructive/10'
|
||||
: 'border-border bg-card'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
{i === 0 && (
|
||||
<Badge className="bg-red-600 hover:bg-red-600 text-[9px] h-4 px-1">
|
||||
<Badge className="bg-destructive hover:bg-destructive text-[9px] h-4 px-1">
|
||||
{t('accounts.runningState.latest')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] font-mono font-bold text-red-500 bg-red-50 px-1.5 py-0.5 rounded break-all">
|
||||
<div className="text-[10px] font-mono font-bold text-destructive bg-destructive/10 px-1.5 py-0.5 rounded break-all">
|
||||
<span className="sm:hidden">
|
||||
{format(new Date(e.at), 'HH:mm')}
|
||||
</span>
|
||||
@@ -448,7 +448,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</div>
|
||||
|
||||
<AlertTriangle
|
||||
className={`w-4 h-4 shrink-0 ${i === 0 ? 'text-red-600' : 'text-red-300'
|
||||
className={`w-4 h-4 shrink-0 ${i === 0 ? 'text-destructive' : 'text-destructive/50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
@@ -456,7 +456,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<p
|
||||
className={`
|
||||
text-xs font-bold leading-relaxed whitespace-pre-wrap break-all min-w-0
|
||||
${i === 0 ? 'text-red-950' : 'text-slate-600'}
|
||||
${i === 0 ? 'text-foreground' : 'text-muted-foreground'}
|
||||
`}
|
||||
>
|
||||
{e.error}
|
||||
@@ -477,7 +477,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</Tabs>
|
||||
<DialogFooter className="px-4 py-3 sm:px-6 sm:py-4 border-t bg-card shrink-0">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" className="w-full sm:w-24 font-bold border-2">{t('common.close')}</Button>
|
||||
<Button variant="outline" className="w-full sm:w-24 font-bold border">{t('common.close')}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -38,20 +38,20 @@ export function FilterResetButton() {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFilter(q ? { q } : {})}
|
||||
className={cn(
|
||||
"h-6 px-2 text-xs gap-1.5 font-normal",
|
||||
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
"h-7 px-2 text-xs gap-1.5 font-medium rounded-md",
|
||||
"text-foreground/70 hover:text-foreground hover:bg-accent transition-all duration-200"
|
||||
)}
|
||||
title={t('search_reset.tooltip')}
|
||||
>
|
||||
<span>{t('search_reset.label')}</span>
|
||||
<div className="flex items-center justify-center w-4 h-4 rounded-full bg-muted-foreground/20 text-[10px]">
|
||||
<div className="flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-bold">
|
||||
{activeFiltersCount}
|
||||
</div>
|
||||
<X className="h-3 w-3" />
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -223,8 +223,8 @@ export function AttachmentListTable({
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
minSize: 130,
|
||||
maxSize: 130,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
// Copyright (c) 2025-2026 rustmailer.com[](https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
@@ -10,7 +10,6 @@
|
||||
//
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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';
|
||||
@@ -25,6 +24,7 @@ import { getToken } from '@/stores/authStore';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import LongText from '@/components/long-text';
|
||||
|
||||
interface DailyActivity {
|
||||
date: string;
|
||||
@@ -75,8 +75,6 @@ const formatTooltipDate = (timestamp_ms: number, locale: string): string => {
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
||||
|
||||
const MetricCardSkeleton = () => (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -91,7 +89,7 @@ const MetricCardSkeleton = () => (
|
||||
);
|
||||
|
||||
const EmptyChart = ({ title }: { title: string }) => (
|
||||
<div className="h-80 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="h-36 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Inbox className="h-12 w-12 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
</div>
|
||||
@@ -157,13 +155,27 @@ export default function MailArchiveDashboard() {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleQuickAttachmentSearch = (filter: Record<string, any>) => {
|
||||
navigate({
|
||||
to: '/attachment',
|
||||
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] },
|
||||
{ name: 'No Attachments', value: 1 - attachmentRatio, fill: '#e5e7eb' },
|
||||
{ name: 'With Attachments', value: attachmentRatio, fill: 'hsl(var(--primary))' },
|
||||
{ name: 'No Attachments', value: 1 - attachmentRatio, fill: 'hsl(var(--muted))' },
|
||||
]
|
||||
: [
|
||||
{ name: 'No Data', value: 1, fill: '#e5e7eb' },
|
||||
{ name: 'No Data', value: 1, fill: 'hsl(var(--muted))' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
@@ -172,7 +184,7 @@ export default function MailArchiveDashboard() {
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => <MetricCardSkeleton key={i} />)}
|
||||
</div>
|
||||
<Skeleton className="h-80 w-full" />
|
||||
<Skeleton className="h-36 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -182,37 +194,55 @@ export default function MailArchiveDashboard() {
|
||||
<FixedHeader />
|
||||
<Main higher>
|
||||
<div className="flex-1 space-y-6 p-6 md:p-8">
|
||||
{/* Top Metrics */}
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-12">
|
||||
<Card className="md:col-span-2 lg:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.mailAccounts')}</CardTitle>
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.mailAccounts')}</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-4xl font-bold">{formatNumber(stats!.account_count)}</div>
|
||||
<div className="text-xl font-bold">{formatNumber(stats!.account_count)}</div>
|
||||
<p className="text-xs text-muted-foreground">{t('dashboard.connected')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-3 lg:col-span-3">
|
||||
<Card className="md:col-span-2 lg:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.totalEmails')}</CardTitle>
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.totalEmails')}</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-4xl font-bold">{formatNumber(stats!.email_count)}</div>
|
||||
<div className="text-xl font-bold">{formatNumber(stats!.email_count)}</div>
|
||||
<p className="text-xs text-muted-foreground">{t('dashboard.syncedLocally')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-primary/20 bg-primary/[0.01] md:col-span-4 lg:col-span-4">
|
||||
<Card className="md:col-span-2 lg:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-bold text-primary flex items-center gap-1 uppercase">
|
||||
<Zap className="h-3.5 w-3.5 fill-primary" />
|
||||
{t('dashboard.efficiency', 'Efficiency')}
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.systemVersion')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-xl font-bold truncate text-primary tracking-tighter'>
|
||||
{stats!.system_version ? (
|
||||
<a href={`https://github.com/rustmailer/bichon/releases/tag/${stats!.system_version}`} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||
{stats!.system_version}
|
||||
</a>
|
||||
) : 'N/A'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<GithubIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{stats!.commit_hash?.substring(0, 7) ?? 'N/A'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="md:col-span-6 lg:col-span-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between pt-2 pb-1 px-4">
|
||||
<CardTitle className="text-xs font-bold flex items-center gap-1 uppercase">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{t('dashboard.efficiency')}
|
||||
</CardTitle>
|
||||
<div className="flex flex-col items-end leading-none">
|
||||
<span className="text-xl font-black text-primary">{savingsPercent}%</span>
|
||||
<span className="text-sm font-black text-primary">{savingsPercent}%</span>
|
||||
<span className="text-[9px] font-bold text-primary uppercase">{t('dashboard.saved', 'Saved')}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -250,198 +280,282 @@ export default function MailArchiveDashboard() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-3 lg:col-span-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.systemVersion')}</CardTitle>
|
||||
<Badge variant="secondary" className="text-[9px] h-4.5 font-bold px-1.5 uppercase tracking-wider">Community</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[3fr_1fr] gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-xs'>{t('dashboard.newEmails')}</CardTitle>
|
||||
<CardDescription className='text-xs'>{t('dashboard.messageDistribution')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-4xl font-bold truncate text-primary tracking-tighter'>
|
||||
{stats!.system_version ? (
|
||||
<a href={`https://github.com/rustmailer/bichon/releases/tag/${stats!.system_version}`} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||
{stats!.system_version}
|
||||
</a>
|
||||
) : 'N/A'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<GithubIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<p className="text-[10px] text-muted-foreground font-mono truncate">{stats!.commit_hash?.substring(0, 7) ?? 'N/A'}</p>
|
||||
</div>
|
||||
<CardContent className="h-36">
|
||||
{hasRecentActivity ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={convertRecentActivity(stats!.recent_activity, currentLocale)} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} opacity={0.3} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} interval="preserveStart" tickCount={10} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
formatter={(v) => formatNumber(v as number)}
|
||||
content={({ payload }) => {
|
||||
if (payload && payload.length) {
|
||||
const dataPoint = payload[0].payload;
|
||||
const fullDate = formatTooltipDate(dataPoint.timestamp_ms, currentLocale);
|
||||
return (
|
||||
<div className="p-2 border rounded-lg shadow-md bg-white dark:bg-gray-800">
|
||||
<p className="font-semibold text-xs mb-1">{fullDate}</p>
|
||||
<p className="text-xs">{t('dashboard.emails')}: {formatNumber(dataPoint.count)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="currentColor" className="text-primary" radius={[4, 4, 0, 0]} barSize={26} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart title={t('dashboard.noRecentActivity')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-xs'>{t('dashboard.attachmentRatio')}</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
{totalAttachments > 0
|
||||
? t('dashboard.attachmentRatioDesc', { percent: (attachmentRatio * 100).toFixed(1) })
|
||||
: t('dashboard.noEmailsSynced')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-center h-36">
|
||||
<ResponsiveContainer width={220} height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={attachmentData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={38}
|
||||
outerRadius={68}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{attachmentData.map((entry, i) => (
|
||||
<Cell key={`cell-${i}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(v) =>
|
||||
totalAttachments > 0 ? `${((v as number) * 100).toFixed(1)}%` : '0%'
|
||||
}
|
||||
contentStyle={{
|
||||
fontSize: '12px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
itemStyle={{
|
||||
fontSize: '12px',
|
||||
}}
|
||||
labelStyle={{
|
||||
fontSize: '12px',
|
||||
}} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="!px-4 !pt-4 !pb-1">
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.top10Senders')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hasTopSenders ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">{t('dashboard.sender')}</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.count')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats!.top_senders.map((s) => (
|
||||
<TableRow key={s.key}>
|
||||
<TableCell>
|
||||
<div className="group relative flex items-center w-full min-w-0 h-full px-2 overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||
<span className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ from: s.key })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors truncate max-w-[258px]"
|
||||
>
|
||||
{s.key}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">{formatNumber(s.count)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyTable title={t('dashboard.noSendersData')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="!px-4 !pt-4 !pb-1">
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.top10LargestEmails')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hasTopEmails ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">{t('dashboard.subject')}</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.size')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats!.top_largest_emails.map((m, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<div className="group relative flex items-center w-full min-w-0 h-full px-2 overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||
<span className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ id: m.id })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors truncate max-w-[258px]"
|
||||
>
|
||||
{m.subject || t('dashboard.noSubject')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">{formatBytes(m.size_bytes)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyTable title={t('dashboard.noLargeEmails')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="!px-4 !pt-4 !pb-1">
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.top10Accounts')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hasTopAccounts ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">{t('dashboard.account')}</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.emails')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats!.top_accounts.map((acc) => (
|
||||
<TableRow key={acc.key}>
|
||||
<TableCell>
|
||||
<div className="group relative flex items-center w-full min-w-0 h-full px-2 overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||
<span className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ account_ids: [getAccountIdByEmail(acc.key) || 0] })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors truncate max-w-[258px]"
|
||||
>
|
||||
{acc.key}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">{formatNumber(acc.count)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyTable title={t('dashboard.noAccountData')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="!px-4 !pt-4 !pb-1">
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">
|
||||
{t('dashboard.top10LargestAttachments')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{stats?.top_largest_attachments?.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">Name</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.size')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats.top_largest_attachments.slice(0, 10).map((a, i) => (
|
||||
<TableRow
|
||||
key={i}
|
||||
onClick={() => handleQuickAttachmentSearch({ id: a.id })}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="group relative flex items-center w-full min-w-0 h-full px-2 overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||
<span className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickAttachmentSearch({ id: a.id })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors truncate max-w-[258px]"
|
||||
>
|
||||
{a.name || 'Unnamed'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{formatBytes(a.size_bytes)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyTable title="No attachment data" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="trend" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3 lg:w-auto">
|
||||
<TabsTrigger value="trend">{t('dashboard.dayTrend')}</TabsTrigger>
|
||||
<TabsTrigger value="attachment">{t('dashboard.attachments')}</TabsTrigger>
|
||||
<TabsTrigger value="top">{t('dashboard.topLists')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="trend" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('dashboard.newEmails')}</CardTitle>
|
||||
<CardDescription>{t('dashboard.messageDistribution')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
{hasRecentActivity ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={convertRecentActivity(stats!.recent_activity, currentLocale)} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} opacity={0.3} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} interval="preserveStart" tickCount={10} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
formatter={(v) => formatNumber(v as number)}
|
||||
content={({ payload }) => {
|
||||
if (payload && payload.length) {
|
||||
const dataPoint = payload[0].payload;
|
||||
const fullDate = formatTooltipDate(dataPoint.timestamp_ms, currentLocale);
|
||||
return (
|
||||
<div className="p-2 border rounded-lg shadow-md bg-white dark:bg-gray-800">
|
||||
<p className="font-semibold text-sm mb-1">{fullDate}</p>
|
||||
<p className="text-xs">{t('dashboard.emails')}: {formatNumber(dataPoint.count)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="currentColor" className="text-primary" radius={[4, 4, 0, 0]} barSize={28} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart title={t('dashboard.noRecentActivity')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="attachment" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('dashboard.attachmentRatio')}</CardTitle>
|
||||
<CardDescription>
|
||||
{totalAttachments > 0
|
||||
? t('dashboard.attachmentRatioDesc', { percent: (attachmentRatio * 100).toFixed(1) })
|
||||
: t('dashboard.noEmailsSynced')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-center h-80">
|
||||
<ResponsiveContainer width={300} height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={attachmentData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{attachmentData.map((entry, i) => (
|
||||
<Cell key={`cell-${i}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => totalAttachments > 0 ? `${((v as number) * 100).toFixed(1)}%` : '0%'} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="top" className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold uppercase">{t('dashboard.top10Senders')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hasTopSenders ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">{t('dashboard.sender')}</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.count')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats!.top_senders.map((s) => (
|
||||
<TableRow key={s.key} className="cursor-pointer hover:bg-accent/50 group" onClick={() => handleQuickSearch({ from: s.key })}>
|
||||
<TableCell className="max-w-[200px] truncate text-sm group-hover:text-primary transition-colors">{s.key}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">{formatNumber(s.count)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyTable title={t('dashboard.noSendersData')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold uppercase">{t('dashboard.top10LargestEmails')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hasTopEmails ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">{t('dashboard.subject')}</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.size')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats!.top_largest_emails.map((m, i) => (
|
||||
<TableRow key={i} className="cursor-pointer hover:bg-accent/50 group" onClick={() => handleQuickSearch({ subject: m.subject })}>
|
||||
<TableCell className="max-w-[200px] truncate text-sm group-hover:text-primary transition-colors">{m.subject || t('dashboard.noSubject')}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm text-orange-600">{formatBytes(m.size_bytes)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyTable title={t('dashboard.noLargeEmails')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold uppercase">{t('dashboard.top10Accounts')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hasTopAccounts ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">{t('dashboard.account')}</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.emails')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats!.top_accounts.map((acc) => (
|
||||
<TableRow key={acc.key} className="cursor-pointer hover:bg-accent/50 group" onClick={() => handleQuickSearch({ account_ids: [getAccountIdByEmail(acc.key) || 0] })}>
|
||||
<TableCell className="max-w-[200px] truncate text-sm group-hover:text-primary transition-colors">{acc.key}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">{formatNumber(acc.count)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyTable title={t('dashboard.noAccountData')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
<div className="mt-auto p-6 text-center text-xs text-muted-foreground border-t">
|
||||
© 2025-2026 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
|
||||
</div>
|
||||
|
||||
@@ -38,20 +38,20 @@ export function FilterResetButton() {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFilter(q ? { q } : {})}
|
||||
className={cn(
|
||||
"h-6 px-2 text-xs gap-1.5 font-normal",
|
||||
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
"h-7 px-2 text-xs gap-1.5 font-medium rounded-md",
|
||||
"text-foreground/70 hover:text-foreground hover:bg-accent transition-all duration-200"
|
||||
)}
|
||||
title={t('search_reset.tooltip')}
|
||||
>
|
||||
<span>{t('search_reset.label')}</span>
|
||||
<div className="flex items-center justify-center w-4 h-4 rounded-full bg-muted-foreground/20 text-[10px]">
|
||||
<div className="flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-bold">
|
||||
{activeFiltersCount}
|
||||
</div>
|
||||
<X className="h-3 w-3" />
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -249,8 +249,8 @@ export function MailListTable({
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
minSize: 130,
|
||||
maxSize: 130,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
||||
+94
-69
@@ -5,76 +5,101 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 271 8% 98%;
|
||||
--foreground: 271 56% 6%;
|
||||
--muted: 271 8% 92%;
|
||||
--muted-foreground: 271 10% 35%;
|
||||
--popover: 271 8% 98%;
|
||||
--popover-foreground: 271 56% 6%;
|
||||
--card: 271 8% 97%;
|
||||
--card-foreground: 271 56% 5%;
|
||||
--border: 271 6% 88%;
|
||||
--input: 271 6% 88%;
|
||||
--primary: 271 60% 66%;
|
||||
--primary-foreground: 0 0% 0%;
|
||||
--secondary: 91 55% 58%;
|
||||
--secondary-foreground: 91 50% 10%;
|
||||
--accent: 91 55% 58%;
|
||||
--accent-foreground: 91 50% 10%;
|
||||
--destructive: 17 85% 48%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--ring: 271 60% 66%;
|
||||
--chart-1: 271 60% 66%;
|
||||
--chart-2: 91 55% 58%;
|
||||
--chart-3: 91 55% 58%;
|
||||
--chart-4: 91 55% 62%;
|
||||
--chart-5: 271 63% 66%;
|
||||
--radius: 0.5rem;
|
||||
/* Sidebar */
|
||||
--sidebar-background: 271 8% 98%;
|
||||
--sidebar-foreground: 271 56% 6%;
|
||||
--sidebar-primary: 271 60% 66%;
|
||||
--sidebar-primary-foreground: 0 0% 0%;
|
||||
--sidebar-accent: 91 55% 58%;
|
||||
--sidebar-accent-foreground: 91 50% 10%;
|
||||
--sidebar-border: 271 6% 88%;
|
||||
--sidebar-ring: 271 60% 66%;
|
||||
}
|
||||
.dark {
|
||||
--background: 271 20% 10%;
|
||||
--foreground: 271 10% 96%;
|
||||
--muted: 271 10% 14%;
|
||||
--muted-foreground: 271 10% 65%;
|
||||
--popover: 271 20% 10%;
|
||||
--popover-foreground: 271 10% 96%;
|
||||
--card: 271 20% 13%;
|
||||
--card-foreground: 0 0% 100%;
|
||||
--border: 271 12% 22%;
|
||||
--input: 271 12% 22%;
|
||||
--primary: 271 50% 66%;
|
||||
--primary-foreground: 0 0% 0%;
|
||||
--secondary: 91 45% 58%;
|
||||
--secondary-foreground: 91 40% 10%;
|
||||
--accent: 91 45% 58%;
|
||||
--accent-foreground: 91 40% 10%;
|
||||
--destructive: 17 85% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--ring: 271 50% 66%;
|
||||
--chart-1: 271 50% 66%;
|
||||
--chart-2: 91 45% 58%;
|
||||
--chart-3: 91 45% 58%;
|
||||
--chart-4: 91 45% 62%;
|
||||
--chart-5: 271 55% 66%;
|
||||
/* 亮色模式:保持清爽,略微加深边框对比度 */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 215 28% 17%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 215 28% 17%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 215 28% 17%;
|
||||
|
||||
/* 提升亮色下的边框清晰度 */
|
||||
--border: 214 32% 88%;
|
||||
--input: 214 32% 88%;
|
||||
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222 47% 11%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 221 83% 53%;
|
||||
--radius: 0.35rem;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-background: 271 20% 10%;
|
||||
--sidebar-foreground: 271 10% 96%;
|
||||
--sidebar-primary: 271 50% 66%;
|
||||
--sidebar-primary-foreground: 0 0% 0%;
|
||||
--sidebar-accent: 91 45% 58%;
|
||||
--sidebar-accent-foreground: 91 40% 10%;
|
||||
--sidebar-border: 271 12% 22%;
|
||||
--sidebar-ring: 271 50% 66%;
|
||||
--chart-1: 221 83% 53%;
|
||||
--chart-2: 200 90% 40%;
|
||||
--chart-3: 35 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
|
||||
--sidebar-background: 210 40% 98%;
|
||||
--sidebar-foreground: 215 28% 17%;
|
||||
--sidebar-primary: 221 83% 53%;
|
||||
--sidebar-primary-foreground: 210 40% 98%;
|
||||
--sidebar-accent: 214 32% 91%;
|
||||
--sidebar-accent-foreground: 215 28% 17%;
|
||||
--sidebar-border: 214 32% 88%;
|
||||
--sidebar-ring: 221 83% 53%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* 背景:采用质感深灰 (Slate 900级别) 而不是纯黑 */
|
||||
--background: 215 20% 12%;
|
||||
--foreground: 210 40% 98%; /* 更明亮的纯白主文本 */
|
||||
|
||||
/* 卡片和悬浮层:比背景明显提亮一个色阶 (Slate 800级别),形成物理层次感 */
|
||||
--card: 215 20% 16%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 215 20% 16%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
/* 弱化背景:用于 Table 表头、Hover 状态,再次提亮 */
|
||||
--muted: 215 20% 22%;
|
||||
/* 弱化文字:明度提高到 70%,确保在深色背景上不费眼 */
|
||||
--muted-foreground: 215 15% 70%;
|
||||
|
||||
/* 边框和输入框:使用高光轮廓线,对比度极强,切分区域极其清晰 */
|
||||
--border: 215 20% 28%;
|
||||
--input: 215 20% 28%;
|
||||
|
||||
/* 主色调:采用穿透力更强的高明度冰蓝色 */
|
||||
--primary: 217 90% 70%;
|
||||
--primary-foreground: 215 20% 12%;
|
||||
|
||||
--secondary: 215 20% 22%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--accent: 215 20% 22%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 70% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 217 90% 70%;
|
||||
|
||||
--chart-1: 217 90% 70%;
|
||||
--chart-2: 200 90% 60%;
|
||||
--chart-3: 35 90% 65%;
|
||||
--chart-4: 280 75% 75%;
|
||||
--chart-5: 340 85% 70%;
|
||||
|
||||
/* 侧边栏:略微比主区域深一点点,起到画框包裹的视觉效果 */
|
||||
--sidebar-background: 215 20% 10%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 217 90% 70%;
|
||||
--sidebar-primary-foreground: 215 20% 12%;
|
||||
--sidebar-accent: 215 20% 22%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 215 20% 28%;
|
||||
--sidebar-ring: 217 90% 70%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "تعتمد على محرك البحث Tantivy",
|
||||
"title": "لوحة التحكم",
|
||||
"top10Accounts": "أفضل 10 حسابات",
|
||||
"top10LargestAttachments": "أكبر 10 مرفقات",
|
||||
"top10LargestEmails": "أكبر 10 رسائل بريد إلكتروني",
|
||||
"top10Senders": "أفضل 10 مرسلين",
|
||||
"topLists": "القوائم الأعلى",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Baseret på Tantivy-søgemaskine",
|
||||
"title": "Oversigt",
|
||||
"top10Accounts": "Top 10 Konti",
|
||||
"top10LargestAttachments": "Top 10 største vedhæftede filer",
|
||||
"top10LargestEmails": "Top 10 Største E-mails",
|
||||
"top10Senders": "Top 10 Afsendere",
|
||||
"topLists": "Toplister",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Basiert auf der Tantivy-Suchmaschine",
|
||||
"title": "Dashboard",
|
||||
"top10Accounts": "Top 10 Konten",
|
||||
"top10LargestAttachments": "Top 10 der größten Anhänge",
|
||||
"top10LargestEmails": "Top 10 größte E-Mails",
|
||||
"top10Senders": "Top 10 Absender",
|
||||
"topLists": "Top-Listen",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Based on Tantivy Search Engine",
|
||||
"title": "Dashboard",
|
||||
"top10Accounts": "Top 10 Accounts",
|
||||
"top10LargestAttachments": "Top 10 largest attachments",
|
||||
"top10LargestEmails": "Top 10 Largest Emails",
|
||||
"top10Senders": "Top 10 Senders",
|
||||
"topLists": "Top Lists",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Basado en el motor de búsqueda Tantivy",
|
||||
"title": "Panel de control",
|
||||
"top10Accounts": "Las 10 principales cuentas",
|
||||
"top10LargestAttachments": "Top 10 archivos adjuntos más grandes",
|
||||
"top10LargestEmails": "Los 10 correos más grandes",
|
||||
"top10Senders": "Los 10 principales remitentes",
|
||||
"topLists": "Listas principales",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Perustuu Tantivy-hakukoneeseen",
|
||||
"title": "Kojelauta",
|
||||
"top10Accounts": "10 parasta tiliä",
|
||||
"top10LargestAttachments": "10 suurinta liitetiedostoa",
|
||||
"top10LargestEmails": "10 suurinta sähköpostia",
|
||||
"top10Senders": "10 parasta lähettäjää",
|
||||
"topLists": "Parhaat listat",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Basé sur le moteur de recherche Tantivy",
|
||||
"title": "Tableau de bord",
|
||||
"top10Accounts": "Top 10 des Comptes",
|
||||
"top10LargestAttachments": "Top 10 des plus grosses pièces jointes",
|
||||
"top10LargestEmails": "Top 10 des E-mails les Plus Volumineux",
|
||||
"top10Senders": "Top 10 des Expéditeurs",
|
||||
"topLists": "Top Listes",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Basato sul motore di ricerca Tantivy",
|
||||
"title": "Dashboard",
|
||||
"top10Accounts": "I 10 Account Principali",
|
||||
"top10LargestAttachments": "Top 10 allegati più grandi",
|
||||
"top10LargestEmails": "Le 10 Email più Grandi",
|
||||
"top10Senders": "I 10 Mittenti Principali",
|
||||
"topLists": "Liste Top",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Tantivy 検索エンジンを搭載",
|
||||
"title": "ダッシュボード",
|
||||
"top10Accounts": "アカウントトップ10",
|
||||
"top10LargestAttachments": "添付ファイルトップ10",
|
||||
"top10LargestEmails": "容量の大きいメールトップ10",
|
||||
"top10Senders": "送信者トップ10",
|
||||
"topLists": "トップリスト",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Tantivy 검색 엔진 기반",
|
||||
"title": "대시보드",
|
||||
"top10Accounts": "상위 10개 계정",
|
||||
"top10LargestAttachments": "가장 큰 첨부 파일 10개",
|
||||
"top10LargestEmails": "상위 10개 최대 크기 이메일",
|
||||
"top10Senders": "상위 10명 발신자",
|
||||
"topLists": "상위 목록",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Gebaseerd op de zoekmachine Tantivy",
|
||||
"title": "Dashboard",
|
||||
"top10Accounts": "Top 10 Accounts",
|
||||
"top10LargestAttachments": "Top 10 grootste bijlagen",
|
||||
"top10LargestEmails": "Top 10 Grootste e-mails",
|
||||
"top10Senders": "Top 10 Afzenders",
|
||||
"topLists": "Toplijsten",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Basert på søkemotoren Tantivy",
|
||||
"title": "Oversikt",
|
||||
"top10Accounts": "Topp 10 kontoer",
|
||||
"top10LargestAttachments": "Topp 10 største vedlegg",
|
||||
"top10LargestEmails": "Topp 10 største e-poster",
|
||||
"top10Senders": "Topp 10 avsendere",
|
||||
"topLists": "Topplister",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Oparte na silniku wyszukiwania Tantivy",
|
||||
"title": "Panel",
|
||||
"top10Accounts": "Top 10 kont",
|
||||
"top10LargestAttachments": "10 największych załączników",
|
||||
"top10LargestEmails": "Top 10 największych wiadomości",
|
||||
"top10Senders": "Top 10 Nadawców",
|
||||
"topLists": "Lista TOP",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Baseado no motor de busca Tantivy",
|
||||
"title": "Painel",
|
||||
"top10Accounts": "Top 10 Contas",
|
||||
"top10LargestAttachments": "Top 10 maiores anexos",
|
||||
"top10LargestEmails": "Top 10 Maiores Emails",
|
||||
"top10Senders": "Top 10 Remetentes",
|
||||
"topLists": "Principais Listas",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "На базе поискового движка Tantivy",
|
||||
"title": "Дашборд",
|
||||
"top10Accounts": "Топ 10 аккаунтов",
|
||||
"top10LargestAttachments": "10 самых больших вложений",
|
||||
"top10LargestEmails": "Топ 10 самых больших писем",
|
||||
"top10Senders": "Топ 10 отправителей",
|
||||
"topLists": "Топ списки",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "Baserat på sökmotorn Tantivy",
|
||||
"title": "Översikt",
|
||||
"top10Accounts": "Topp 10 konton",
|
||||
"top10LargestAttachments": "Topp 10 största bilagorna",
|
||||
"top10LargestEmails": "Topp 10 största e-postmeddelanden",
|
||||
"top10Senders": "Topp 10 avsändare",
|
||||
"topLists": "Topplistor",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "基于 Tantivy 搜索引擎",
|
||||
"title": "儀表板",
|
||||
"top10Accounts": "前 10 名帳號",
|
||||
"top10LargestAttachments": "前10個最大附件",
|
||||
"top10LargestEmails": "前 10 封最大郵件",
|
||||
"top10Senders": "前 10 名寄件人",
|
||||
"topLists": "排行榜",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"tantivyDatabase": "基於 Tantivy 搜尋引擎",
|
||||
"title": "仪表板",
|
||||
"top10Accounts": "前10名账户",
|
||||
"top10LargestAttachments": "前10个最大附件",
|
||||
"top10LargestEmails": "前10大邮件",
|
||||
"top10Senders": "前10名发件人",
|
||||
"topLists": "排行榜",
|
||||
|
||||
Reference in New Issue
Block a user