mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
i18n
This commit is contained in:
@@ -90,7 +90,7 @@ export function AccountPopover() {
|
||||
)}
|
||||
>
|
||||
<AtSign className="h-4 w-4" />
|
||||
Account
|
||||
{t('search_accounts.label')}
|
||||
{selectedIds.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -108,7 +108,7 @@ export function AccountPopover() {
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('search.searchAccount')}
|
||||
placeholder={t('search_accounts.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -124,7 +124,7 @@ export function AccountPopover() {
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="flex-1 text-left">
|
||||
{t('search.clearAccounts')}
|
||||
{t('search_accounts.clear_accounts')}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60 font-mono">
|
||||
({selectedIds.length})
|
||||
@@ -134,46 +134,52 @@ export function AccountPopover() {
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{filtered.map(account => {
|
||||
const checked = selectedIds.includes(account.id)
|
||||
const id = `account-${account.id}`
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('search_accounts.no_accounts_found')}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map(account => {
|
||||
const checked = selectedIds.includes(account.id)
|
||||
const id = `account-${account.id}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
onClick={() => toggleAccount(account.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
||||
'hover:bg-accent transition-colors'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
toggleAccount(account.id)
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 truncate text-xs cursor-pointer"
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
onClick={() => toggleAccount(account.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
||||
'hover:bg-accent transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">
|
||||
{account.email}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
#{account.id}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
toggleAccount(account.id)
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 truncate text-xs cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">
|
||||
{account.email}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
#{account.id}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"
|
||||
import { Check, ChevronDown, Mail, X } from "lucide-react"
|
||||
import React from "react"
|
||||
import { useContacts } from "@/hooks/use-contacts"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from "@/components/ui/command"
|
||||
|
||||
export function MailFilterPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const fields = ['from', 'to', 'cc', 'bcc'] as const
|
||||
|
||||
@@ -47,7 +49,11 @@ export function MailFilterPopover() {
|
||||
)}
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 opacity-60" />
|
||||
<span>{activeCount > 0 ? `Participants (${activeCount})` : 'Participants'}</span>
|
||||
<span>
|
||||
{activeCount > 0
|
||||
? t('search_contacts.label_with_count', { count: activeCount })
|
||||
: t('search_contacts.label')}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -69,14 +75,19 @@ export function MailFilterPopover() {
|
||||
</div>
|
||||
|
||||
{activeCount > 0 && (
|
||||
<div className="p-2 flex justify-end bg-background">
|
||||
<div className="px-1 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs font-medium text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={resetAll}
|
||||
className="flex h-8 w-full items-center justify-start gap-2 px-2 text-xs font-medium text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
>
|
||||
Reset All Participants
|
||||
<div className="flex h-4 w-4 items-center justify-center">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="flex-1 text-left">
|
||||
{t('search_contacts.reset_all')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -96,6 +107,7 @@ function ContactSelectorField({
|
||||
onSelect: (email: string | undefined) => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
const { contacts, isLoading } = useContacts(searchTerm)
|
||||
|
||||
@@ -129,7 +141,7 @@ function ContactSelectorField({
|
||||
: "text-xs text-muted-foreground/90"
|
||||
)}
|
||||
>
|
||||
{value || 'Any'}
|
||||
{value || t('search_contacts.any')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -161,16 +173,16 @@ function ContactSelectorField({
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={`Search ${label}...`}
|
||||
placeholder={t('search_contacts.search_placeholder', { field: label })}
|
||||
className="h-9"
|
||||
value={searchTerm}
|
||||
onValueChange={setSearchTerm}
|
||||
/>
|
||||
<CommandList className="max-h-[360px]">
|
||||
{isLoading && (
|
||||
<div className="p-4 text-xs text-center opacity-50">Loading...</div>
|
||||
<div className="p-4 text-xs text-center opacity-50">{t('search_contacts.loading')}</div>
|
||||
)}
|
||||
<CommandEmpty>No contact found.</CommandEmpty>
|
||||
<CommandEmpty>{t('search_contacts.no_contact_found')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{contacts.slice(0, 100).map((email) => (
|
||||
<CommandItem
|
||||
@@ -193,7 +205,7 @@ function ContactSelectorField({
|
||||
))}
|
||||
{contacts.length > 100 && (
|
||||
<div className="px-3 py-2 text-[10px] text-center text-muted-foreground border-t border-border/50">
|
||||
Showing top 100 results • {contacts.length} total
|
||||
{t('search_contacts.showing_limit', { total: contacts.length })}
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
|
||||
@@ -21,7 +21,7 @@ import React from 'react'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { SortingState } from '@tanstack/react-table'
|
||||
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore'
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore'
|
||||
|
||||
interface SearchContextType {
|
||||
open: SearchDialogType | null
|
||||
|
||||
@@ -2,11 +2,13 @@ import { X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useSearchContext } from "./context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function FilterResetButton() {
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { q, ...restFilters } = filter;
|
||||
|
||||
const activeFiltersCount = Object.keys(restFilters).filter(key => {
|
||||
const value = restFilters[key];
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
@@ -24,8 +26,9 @@ export function FilterResetButton() {
|
||||
"h-8 px-2 text-xs gap-1.5 font-normal",
|
||||
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
)}
|
||||
title={t('search_reset.tooltip')}
|
||||
>
|
||||
<span>Reset</span>
|
||||
<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]">
|
||||
{activeFiltersCount}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FixedHeader } from '@/components/layout/fixed-header';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { useSearchMessages } from '@/hooks/use-search-messages';
|
||||
import { SearchFormDialog } from './search-form';
|
||||
import { EnvelopeListPagination } from '@/components/pagination';
|
||||
import React from 'react';
|
||||
import { EmailEnvelope } from '@/api';
|
||||
@@ -49,15 +48,12 @@ export default function Search() {
|
||||
total,
|
||||
totalPages,
|
||||
isLoading,
|
||||
isFetching,
|
||||
page,
|
||||
pageSize,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSortBy,
|
||||
setSortOrder,
|
||||
onSubmit,
|
||||
reset,
|
||||
filter,
|
||||
setFilter
|
||||
} = useSearchMessages();
|
||||
@@ -67,12 +63,6 @@ export default function Search() {
|
||||
setPageSize(pageSize)
|
||||
}
|
||||
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setSelectedTags([]);
|
||||
};
|
||||
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag)
|
||||
@@ -125,21 +115,6 @@ export default function Search() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* {!isLoading && total === 0 && <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="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
|
||||
alt="Bichon Logo"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg font-semibold">{t('search.noEmailsFound')}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
{Object.keys(filter).length === 0
|
||||
? t('search.startSearching')
|
||||
: t('search.adjustSearch')}
|
||||
</p>
|
||||
</div>
|
||||
</div>} */}
|
||||
<MailListTable
|
||||
isLoading={isLoading}
|
||||
items={emails}
|
||||
@@ -180,13 +155,6 @@ export default function Search() {
|
||||
onOpenChange={() => setOpen('edit-tags')}
|
||||
/>
|
||||
|
||||
<SearchFormDialog
|
||||
key='search-form-dialog'
|
||||
onSubmit={onSubmit} isLoading={isLoading || isFetching} reset={handleReset}
|
||||
open={open === 'search-form'}
|
||||
onOpenChange={() => setOpen('search-form')}
|
||||
/>
|
||||
|
||||
<RestoreMessageDialog
|
||||
key='restore-mail-dialog'
|
||||
open={open === 'restore'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronDown, Folders, X } from 'lucide-react'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Popover,
|
||||
@@ -31,6 +32,7 @@ import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
export function MailboxPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { minimalList = [] } = useMinimalAccountList()
|
||||
|
||||
@@ -123,7 +125,7 @@ export function MailboxPopover() {
|
||||
)}
|
||||
>
|
||||
<Folders className="h-4 w-4" />
|
||||
Mailbox
|
||||
{t('search_mailbox.label')}
|
||||
{selectedMailboxIds.length > 0 && (
|
||||
<span className="ml-1 text-xs opacity-70">
|
||||
{selectedMailboxIds.length}
|
||||
@@ -138,7 +140,7 @@ export function MailboxPopover() {
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search mailbox"
|
||||
placeholder={t('search_mailbox.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -151,14 +153,14 @@ export function MailboxPopover() {
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="mr-2 h-3.5 w-3.5" />
|
||||
Clear Mailboxes ({selectedMailboxIds.length})
|
||||
{t('search_mailbox.clear_mailboxes')} ({selectedMailboxIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{disabled ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
Please select account first
|
||||
{t('search_mailbox.select_account_first')}
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<div className="space-y-2 p-2">
|
||||
@@ -171,7 +173,7 @@ export function MailboxPopover() {
|
||||
</div>
|
||||
) : grouped.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
No mailbox found
|
||||
{t('search_mailbox.no_mailbox_found')}
|
||||
</p>
|
||||
) : (
|
||||
<Accordion
|
||||
@@ -258,4 +260,4 @@ export function MailboxPopover() {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function MoreFiltersPopover() {
|
||||
)}
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">Advanced</span>
|
||||
<span className="text-xs">{t('search_more.trigger_label')}</span>
|
||||
{activeCount > 0 && (
|
||||
<Badge className="ml-1 h-4 px-1 text-[10px] bg-primary text-primary-foreground border-none rounded-sm">
|
||||
{activeCount}
|
||||
@@ -105,7 +105,7 @@ export function MoreFiltersPopover() {
|
||||
|
||||
<PopoverContent align="end" className="w-72 p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium">Advanced Filters</h4>
|
||||
<h4 className="text-xs font-medium">{t('search_more.title')}</h4>
|
||||
{activeCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -123,7 +123,7 @@ export function MoreFiltersPopover() {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
{t('search_more.reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -140,22 +140,22 @@ export function MoreFiltersPopover() {
|
||||
htmlFor="has_attachment"
|
||||
className="text-xs font-normal cursor-pointer select-none"
|
||||
>
|
||||
Has Attachments
|
||||
{t('search_more.has_attachment')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Attachment Name</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.attachment_name_label')}</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={localState.attachment_name}
|
||||
onChange={(e) => setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))}
|
||||
placeholder="e.g. invoice.pdf"
|
||||
placeholder={t('search_more.attachment_name_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Message Size</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.message_size_label')}</Label>
|
||||
<Select
|
||||
value={localState.size_preset}
|
||||
onValueChange={(v) => setLocalState(prev => ({ ...prev, size_preset: v }))}
|
||||
@@ -164,30 +164,29 @@ export function MoreFiltersPopover() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem className="text-xs" value="any">{t('search.any')}</SelectItem>
|
||||
<SelectItem className="text-xs" value="tiny">{t('search.tiny')}</SelectItem>
|
||||
<SelectItem className="text-xs" value="small">{t('search.small')}</SelectItem>
|
||||
<SelectItem className="text-xs" value="medium">{t('search.medium')}</SelectItem>
|
||||
<SelectItem className="text-xs" value="large">{t('search.large')}</SelectItem>
|
||||
<SelectItem className="text-xs" value="huge">{t('search.huge')}</SelectItem>
|
||||
{Object.keys(SIZES).concat('any').map((key) => (
|
||||
<SelectItem key={key} className="text-xs" value={key}>
|
||||
{t(`search_more.size_presets.${key}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Original Message ID</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.message_id_label')}</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={localState.message_id}
|
||||
onChange={(e) => setLocalState(prev => ({ ...prev, message_id: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground opacity-70 leading-tight">
|
||||
{t('search.originalMessageIdHeader')}
|
||||
{t('search_more.message_id_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button size="sm" className="w-full h-8 text-xs mt-2" onClick={handleApply}>
|
||||
Apply Filters
|
||||
{t('search_more.apply')}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -1,506 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { DatePicker } from "@/components/date-picker";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ChevronDown, ChevronUp, Filter, RotateCcw } from "lucide-react";
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { VirtualizedSelect } from "@/components/virtualized-select";
|
||||
import useMinimalAccountList from "@/hooks/use-minimal-account-list";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { list_mailboxes, MailboxData } from "@/api/mailbox/api";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useSearchContext } from "./context";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const searchFilterSchema = z.object({
|
||||
text: z.string().optional().or(z.literal("")),
|
||||
from: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
to: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
cc: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
bcc: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
has_attachment: z.boolean().optional(),
|
||||
attachment_name: z.string().optional().or(z.literal("")),
|
||||
since: z.date().optional(),
|
||||
before: z.date().optional(),
|
||||
account_ids: z.array(z.number()),
|
||||
mailbox_ids: z.array(z.number()),
|
||||
size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large', 'huge']).optional(),
|
||||
message_id: z.string().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
type SearchFilterForm = z.infer<typeof searchFilterSchema>;
|
||||
|
||||
|
||||
interface Props {
|
||||
onSubmit: (values: Record<string, any>) => void,
|
||||
isLoading: boolean,
|
||||
reset: () => void,
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const isEmptyValue = (value: any): boolean => {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (value === '') return true;
|
||||
if (typeof value === 'string' && value.trim() === '') return true;
|
||||
if (typeof value === 'number' && isNaN(value)) return true;
|
||||
if (value === false) return true;
|
||||
if (value === 0) return true;
|
||||
if (Array.isArray(value) && value.length === 0) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const cleanEmpty = <T extends Record<string, any>>(obj: T): Partial<T> => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, value]) => !isEmptyValue(value))
|
||||
) as Partial<T>;
|
||||
};
|
||||
|
||||
function withSizePreset(values: Record<string, any>) {
|
||||
const { size_preset, ...rest } = values;
|
||||
|
||||
switch (size_preset) {
|
||||
case 'tiny':
|
||||
return { ...rest, max_size: 15 * 1024 };
|
||||
case 'small':
|
||||
return { ...rest, max_size: 2 * 1024 * 1024 };
|
||||
case 'medium':
|
||||
return { ...rest, min_size: 2 * 1024 * 1024, max_size: 10 * 1024 * 1024 };
|
||||
case 'large':
|
||||
return { ...rest, min_size: 10 * 1024 * 1024, max_size: 20 * 1024 * 1024 };
|
||||
case 'huge':
|
||||
return { ...rest, min_size: 20 * 1024 * 1024 };
|
||||
default:
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
|
||||
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
|
||||
const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList();
|
||||
const { selectedTags } = useSearchContext();
|
||||
|
||||
const form = useForm<SearchFilterForm>({
|
||||
resolver: zodResolver(searchFilterSchema),
|
||||
defaultValues: {
|
||||
text: "",
|
||||
from: "",
|
||||
to: "",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
attachment_name: "",
|
||||
message_id: "",
|
||||
size_preset: 'any',
|
||||
has_attachment: false,
|
||||
since: undefined,
|
||||
before: undefined,
|
||||
account_ids: [],
|
||||
mailbox_ids: [],
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { mailboxes, isMailboxesLoading } = useQueries({
|
||||
queries: selectedAccountIds.map((id) => ({
|
||||
queryKey: ['search-account-mailboxes', id],
|
||||
queryFn: () => list_mailboxes(id!, false),
|
||||
})),
|
||||
combine: (results) => ({
|
||||
mailboxes: results.flatMap((result) => result.data?.sort((a, b) => a.name.localeCompare(b.name))),
|
||||
isMailboxesLoading: results.some((result) => result.isLoading),
|
||||
}),
|
||||
})
|
||||
|
||||
const mailboxesOptions = mailboxes?.filter((item) => !!item).map((mailbox: MailboxData) => ({
|
||||
value: mailbox.id.toString(),
|
||||
label: mailbox.name,
|
||||
description: accountsOptions.find((item) => Number(item.value) === mailbox.account_id)!.label
|
||||
})) || [];
|
||||
|
||||
|
||||
const handleSubmit = (values: Record<string, any>) => {
|
||||
let cleaned = cleanEmpty(values);
|
||||
const payload = withSizePreset(cleaned);
|
||||
|
||||
const finalPayload =
|
||||
selectedTags.length > 0
|
||||
? { ...payload, tags: selectedTags }
|
||||
: payload;
|
||||
|
||||
if (Object.keys(finalPayload).length > 0) {
|
||||
onSubmit(finalPayload);
|
||||
} else {
|
||||
toast({
|
||||
title: t('search.pleaseSelectAtLeastOne'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
form.reset({
|
||||
text: "",
|
||||
from: "",
|
||||
to: "",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
has_attachment: false,
|
||||
attachment_name: "",
|
||||
since: undefined,
|
||||
before: undefined,
|
||||
account_ids: [],
|
||||
mailbox_ids: [],
|
||||
size_preset: 'any',
|
||||
message_id: "",
|
||||
});
|
||||
setSelectedAccountIds([]);
|
||||
}
|
||||
|
||||
return (<Sheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<SheetContent className='w-full md:max-w-4xl mx-auto'>
|
||||
<SheetHeader className="p-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{t('search.searchArchivedEmails')}
|
||||
</SheetTitle>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetDescription>
|
||||
{t('search.fullTextMultiAccount')}
|
||||
</SheetDescription>
|
||||
<Form {...form}>
|
||||
<form id="email-search-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="account_ids"
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-[180px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.account')}</FormLabel>
|
||||
<FormControl className="flex-1">
|
||||
<VirtualizedSelect
|
||||
options={accountsOptions}
|
||||
isLoading={accountsIsLoading}
|
||||
onSelectOption={(values) => {
|
||||
const ids = values.map((id) => parseInt(id, 10)).sort()
|
||||
setSelectedAccountIds(ids)
|
||||
field.onChange(ids)
|
||||
}}
|
||||
value={field.value.map(String)}
|
||||
placeholder={t('search.selectAccount')}
|
||||
className="h-10 w-full"
|
||||
noItemsComponent={
|
||||
<div className="p-2">
|
||||
<p className="text-xs">{t('search.noActiveEmailAccount')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: "/accounts" })}
|
||||
>
|
||||
{t('search.addEmailAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
multiple
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mailbox_ids"
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-[180px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.mailbox')}</FormLabel>
|
||||
<FormControl className="flex-1">
|
||||
<VirtualizedSelect
|
||||
options={mailboxesOptions}
|
||||
isLoading={isMailboxesLoading}
|
||||
onSelectOption={(values) => {
|
||||
field.onChange(values.map((id) => parseInt(id, 10)))
|
||||
}}
|
||||
value={field.value.map(String)}
|
||||
placeholder={t('search.selectMailbox')}
|
||||
className="h-10 w-full"
|
||||
noItemsComponent={
|
||||
<div className="p-2">
|
||||
<p className="text-xs">
|
||||
{t('search.noMailboxSelectAccount')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
multiple
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('search.searchInSubjectBody')}
|
||||
className="h-11 text-base"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 sm:ml-auto sm:self-center">
|
||||
<Button type="submit" className="h-11 px-6" disabled={isLoading}>
|
||||
{isLoading ? t('search.searchingButton') : <>{t('search.searchButton')}</>}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
{t('search.advanced')}
|
||||
{showAdvanced ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => { handleClear(); reset(); }}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
{t('search.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="since"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.since')}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
placeholder={t('search.selectDate')}
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="before"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.before')}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
placeholder={t('search.selectDate')}
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="has_attachment"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="attach"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<FormLabel htmlFor="attach" className="cursor-pointer text-sm font-normal">
|
||||
{t('search.hasAttachment')}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{showAdvanced && <Accordion type="multiple" className="space-y-3">
|
||||
<AccordionItem value="people">
|
||||
<AccordionTrigger className="text-sm">
|
||||
{t('search.sender')} / {t('search.recipient')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 pt-2">
|
||||
{(['from', 'to', 'cc', 'bcc'] as const).map((key) => (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="capitalize text-xs">
|
||||
{key === 'from' ? t('search.from') : key === 'to' ? t('search.to') : key === 'cc' ? t('search.cc') : t('search.bcc')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={`${key}@example.com`}
|
||||
className="h-9"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="attachment">
|
||||
<AccordionTrigger className="text-sm">
|
||||
{t('search.attachmentsSize')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attachment_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">{t('search.attachmentName')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="invoice.pdf" className="h-9" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="size_preset"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">
|
||||
{t('search.size')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder={t('search.any')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">{t('search.any')}</SelectItem>
|
||||
<SelectItem value="tiny">{t('search.tiny')}</SelectItem>
|
||||
<SelectItem value="small">{t('search.small')}</SelectItem>
|
||||
<SelectItem value="medium">{t('search.medium')}</SelectItem>
|
||||
<SelectItem value="large">{t('search.large')}</SelectItem>
|
||||
<SelectItem value="huge">{t('search.huge')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
<FormDescription className="text-xs">
|
||||
{t('search.sizeDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="ids">
|
||||
<AccordionTrigger className="text-sm">
|
||||
{t('search.messageId')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormControl>
|
||||
<Input placeholder="<abc123@example.com>" className="h-9" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{t('search.originalMessageIdHeader')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
{children && (<>{children(table)}</>)}
|
||||
<ScrollArea className='h-[calc(100vh-13rem)] rounded-md border' orientation='both'>
|
||||
<ScrollArea className='h-[calc(100vh-15rem)] rounded-md border' orientation='both'>
|
||||
<ShadcnTable>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -22,7 +22,6 @@ const defaultColumns = (t: (key: string) => string) => [
|
||||
{ label: t('search.from'), value: "from" },
|
||||
{ label: t('search.to'), value: "to" },
|
||||
{ label: t('search.subject'), value: "subject" },
|
||||
{ label: t('mail.attachments'), value: "attachments" },
|
||||
{ label: t('search.size'), value: "size" },
|
||||
{ label: t('search.date'), value: "date" },
|
||||
]
|
||||
@@ -54,11 +53,11 @@ export function DataTableViewOptions<TData>({
|
||||
className='ms-auto hidden h-8 lg:flex rounded-none'
|
||||
>
|
||||
<MixerHorizontalIcon className='size-4' />
|
||||
View
|
||||
{t('search_view.button_label')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||
<DropdownMenuLabel className='text-xs'>Toggle columns</DropdownMenuLabel>
|
||||
<DropdownMenuLabel className='text-xs'>{t('search_view.menu_title')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { ChevronDown, ChevronUp, Tag } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useAvailableTags } from '@/hooks/use-available-tags';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EnvelopeTagsProps {
|
||||
selectedTags: string[];
|
||||
onTagToggle: (tag: string) => void;
|
||||
}
|
||||
|
||||
export function EnvelopeTags({ selectedTags, onTagToggle }: EnvelopeTagsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = React.useState(true);
|
||||
|
||||
const {
|
||||
tagsCount: tagsCount = [],
|
||||
isLoading: tagsIsLoading,
|
||||
} = useAvailableTags();
|
||||
|
||||
const sortedTags = React.useMemo(() => {
|
||||
return [...tagsCount].sort((a, b) => b.count - a.count);
|
||||
}, [tagsCount]);
|
||||
|
||||
if (tagsIsLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-2 py-1.5">
|
||||
<div className="h-4 w-4 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 flex-1 bg-muted animate-pulse rounded" />
|
||||
<div className="h-5 w-10 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="space-y-2">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-sm font-medium hover:text-primary transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
{t('mail.tags')}
|
||||
{selectedTags.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-xs">
|
||||
{selectedTags.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{open ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="space-y-0">
|
||||
{sortedTags.length === 0 ? (
|
||||
<p className="py-2 pl-2 text-sm text-muted-foreground">{t('mail.noTagsYet')}</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[calc(100vh-12rem)] w-full pr-4 -mr-4">
|
||||
{sortedTags.map(({ tag: facet, count }) => {
|
||||
const checked = selectedTags.includes(facet);
|
||||
const id = `tag-${facet}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={facet}
|
||||
className="flex items-center gap-3 px-2 py-0.5 hover:bg-accent/80 rounded-md transition-colors cursor-pointer group"
|
||||
onClick={() => onTagToggle(facet)}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={() => onTagToggle(facet)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 max-w-[140px] lg:max-w-[120px] cursor-pointer truncate text-sm font-medium"
|
||||
title={facet}
|
||||
>
|
||||
{facet}
|
||||
</Label>
|
||||
<div className="shrink-0 ml-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-xs font-medium min-w-[1.75rem] text-center"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export function TagFilterPopover() {
|
||||
)}
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
{t('mail.tags')}
|
||||
{t('tag.label')}
|
||||
{selectedTags.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -107,7 +107,7 @@ export function TagFilterPopover() {
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('mail.searchTags')}
|
||||
placeholder={t('tag.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -123,7 +123,7 @@ export function TagFilterPopover() {
|
||||
<X className="h-3 w-3" />
|
||||
</div>
|
||||
<span className="flex-1 text-xs font-medium">
|
||||
{t('common.clear_all_tags')}
|
||||
{t('tag.clear_all')}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60">({selectedTags.length})</span>
|
||||
</div>
|
||||
@@ -141,7 +141,7 @@ export function TagFilterPopover() {
|
||||
</div>
|
||||
) : filteredTags.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('mail.noTagsFound')}
|
||||
{t('tag.no_tags_found')}
|
||||
</p>
|
||||
) : (
|
||||
filteredTags.map(({ tag, count }) => {
|
||||
@@ -190,4 +190,4 @@ export function TagFilterPopover() {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Search, X, Clock, Trash2 } from "lucide-react"
|
||||
import { Search, X, Clock, Trash2, Info } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSearchContext } from "./context"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
const STORAGE_KEY = "mail_search_history"
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
export function TextSearchInput() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [value, setValue] = useState(filter.text || "")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
@@ -31,6 +33,7 @@ export function TextSearchInput() {
|
||||
setValue(filter.text || "")
|
||||
}, [filter.text])
|
||||
|
||||
|
||||
const saveToHistory = (term: string) => {
|
||||
if (!term.trim()) return
|
||||
|
||||
@@ -82,6 +85,7 @@ export function TextSearchInput() {
|
||||
const handleSelectHistory = (term: string) => {
|
||||
setValue(term)
|
||||
setShowHistory(false)
|
||||
// 如果需要点击历史立即搜索,可以在这里调用 handleSearch()
|
||||
}
|
||||
|
||||
const handleClearHistory = () => {
|
||||
@@ -108,49 +112,59 @@ export function TextSearchInput() {
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-[550px] min-w-[280px]">
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Search messages... (use "double quotes" for exact phrases)'
|
||||
className={cn(
|
||||
"h-9 pl-9 pr-9 text-sm",
|
||||
isActive && "border-primary/50 focus-visible:ring-primary/30"
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('search_input.placeholder')}
|
||||
className={cn(
|
||||
"h-9 pl-9 pr-9 text-sm",
|
||||
isActive && "border-primary/50 focus-visible:ring-primary/30"
|
||||
)}
|
||||
/>
|
||||
{value && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
{value && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 px-5"
|
||||
onClick={handleSearch}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
{t('search_input.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 px-5"
|
||||
onClick={handleSearch}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
{/* 搜索范围提示 */}
|
||||
<div className="flex items-center gap-1 px-1 opacity-60">
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t('search_input.hint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHistory && (
|
||||
<div className="absolute top-full left-0 w-full mt-1 bg-popover border rounded-md shadow-md z-50 max-h-[280px] overflow-auto">
|
||||
<div className="py-1.5 px-3 text-xs text-muted-foreground font-medium border-b flex items-center justify-between">
|
||||
<div className="absolute top-10 left-0 w-full mt-1 bg-popover border rounded-md shadow-md z-50 max-h-[280px] overflow-auto">
|
||||
<div className="py-1.5 px-3 text-xs text-muted-foreground font-medium border-b flex items-center justify-between sticky top-0 bg-popover z-10">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
Recent searches
|
||||
{t('search_input.recent_title')}
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
@@ -158,7 +172,7 @@ export function TextSearchInput() {
|
||||
className="text-xs text-destructive hover:text-destructive/80 flex items-center gap-1 hover:underline"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Clear all
|
||||
{t('search_input.clear_history')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -176,7 +190,7 @@ export function TextSearchInput() {
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-4 text-xs text-center text-muted-foreground">
|
||||
No recent searches
|
||||
{t('search_input.no_history')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { CalendarRange, ChevronDown, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -14,12 +16,24 @@ import { DatePicker } from '@/components/date-picker'
|
||||
const DAY = 86400000
|
||||
|
||||
export function TimePopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [customDays, setCustomDays] = React.useState<string>('')
|
||||
|
||||
const since = filter.since
|
||||
const before = filter.before
|
||||
|
||||
const toDate = (ts: number) => {
|
||||
return format(ts, t('time.format'))
|
||||
}
|
||||
|
||||
const label = (s?: number, b?: number) => {
|
||||
if (!s && !b) return t('time.label')
|
||||
if (s && b) return `${toDate(s)} → ${toDate(b)}`
|
||||
if (s) return `${t('time.since')} ${toDate(s)}`
|
||||
return `${t('time.before')} ${toDate(b!)}`
|
||||
}
|
||||
|
||||
const setRange = (s?: number, b?: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
@@ -62,23 +76,23 @@ export function TimePopover() {
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="w-[530px] p-4 space-y-6">
|
||||
<Section title="Recent Range (Since...)">
|
||||
<Section title={t('time.recent_range')}>
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1, 7, 30].map(d => (
|
||||
<Quick key={d} onClick={() => setRange(Date.now() - d * DAY, undefined)}>
|
||||
Last {d === 1 ? 'day' : `${d} days`}
|
||||
{d === 1 ? t('time.last_day') : t('time.last_days', { count: d })}
|
||||
</Quick>
|
||||
))}
|
||||
{[3, 6].map(m => (
|
||||
<Quick key={m} onClick={() => setRange(Date.now() - m * 30 * DAY, undefined)}>
|
||||
Last {m} months
|
||||
{t('time.last_months', { count: m })}
|
||||
</Quick>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-border/50">
|
||||
<span className="text-[10px] uppercase font-bold opacity-40 shrink-0">Recent:</span>
|
||||
<span className="text-[10px] uppercase font-bold opacity-40 shrink-0">{t('time.recent_prefix')}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -88,19 +102,19 @@ export function TimePopover() {
|
||||
onChange={e => setCustomDays(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleApplyRecent()}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground shrink-0">days ago to now</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{t('time.days_ago_to_now')}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 ml-auto text-xs"
|
||||
onClick={handleApplyRecent}
|
||||
>
|
||||
Apply
|
||||
{t('time.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Historical (Older than...)">
|
||||
<Section title={t('time.historical')}>
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
{[1, 2, 3, 5, 10].map(y => (
|
||||
<Quick
|
||||
@@ -108,25 +122,28 @@ export function TimePopover() {
|
||||
onClick={() => setRange(undefined, Date.now() - y * 365 * DAY)}
|
||||
className="border-orange-200 hover:border-orange-400 hover:text-orange-600"
|
||||
>
|
||||
Over {y} {y === 1 ? 'year' : 'years'} ago
|
||||
{t('time.over_years_ago', {
|
||||
count: y,
|
||||
unit: y === 1 ? t('time.year') : t('time.years')
|
||||
})}
|
||||
</Quick>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Absolute Date Range">
|
||||
<Section title={t('time.absolute_range')}>
|
||||
<div className="flex gap-3 w-full">
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<span className="text-[10px] pl-1 opacity-50 font-medium">SINCE</span>
|
||||
<span className="text-[10px] pl-1 opacity-50 font-medium">{t('time.since').toUpperCase()}</span>
|
||||
<DatePicker
|
||||
placeholder="Start date"
|
||||
placeholder={t('time.start_date')}
|
||||
selected={since ? new Date(since) : undefined}
|
||||
onSelect={(date) => setSince(date?.getTime())}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<span className="text-[10px] pl-1 opacity-50 font-medium">BEFORE</span>
|
||||
<span className="text-[10px] pl-1 opacity-50 font-medium">{t('time.before').toUpperCase()}</span>
|
||||
<DatePicker
|
||||
placeholder="End date"
|
||||
placeholder={t('time.end_date')}
|
||||
selected={before ? new Date(before) : undefined}
|
||||
onSelect={(date) => setBefore(date?.getTime())}
|
||||
/>
|
||||
@@ -143,7 +160,7 @@ export function TimePopover() {
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<X className="mr-2 h-3.5 w-3.5" />
|
||||
Clear time filters
|
||||
{t('time.clear_filters')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -152,18 +169,6 @@ export function TimePopover() {
|
||||
)
|
||||
}
|
||||
|
||||
function toDate(ts: number) {
|
||||
const d = new Date(ts)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function label(s?: number, b?: number) {
|
||||
if (!s && !b) return 'Time'
|
||||
if (s && b) return `${toDate(s)} → ${toDate(b)}`
|
||||
if (s) return `Since ${toDate(s)}`
|
||||
return `Older than ${toDate(b!)}`
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start w-full">
|
||||
|
||||
Reference in New Issue
Block a user