feat(search): add advanced attachment filters for extension, category and mime type

This commit is contained in:
rustmailer
2026-03-19 20:23:20 +08:00
parent 884fdeba10
commit c19f3977ba
31 changed files with 466 additions and 40 deletions
@@ -0,0 +1,100 @@
import * as React from "react"
import { Check, X } from "lucide-react"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { cn } from "@/lib/utils"
import { useTranslation } from "react-i18next"
interface MetadataSelectorFieldProps {
label: string
value?: string
options: string[]
isLoading: boolean
onSelect: (val: string | undefined) => void
onReset: () => void
}
export function MetadataSelectorField({
label,
value,
options,
isLoading,
onSelect,
onReset
}: MetadataSelectorFieldProps) {
const { t } = useTranslation()
const [searchTerm, setSearchTerm] = React.useState("")
const filteredOptions = React.useMemo(() => {
return options.filter(opt =>
opt.toLowerCase().includes(searchTerm.toLowerCase())
)
}, [options, searchTerm])
return (
<Popover>
<PopoverTrigger asChild>
<button
className={cn(
"group flex items-center justify-between w-full px-4 py-2 hover:bg-accent/50 transition-all text-left relative border rounded-md",
"min-h-[48px]",
value && "bg-accent/30 border-primary/50"
)}
>
<div className="flex flex-col items-start pr-6 overflow-hidden">
<span className="text-[10px] font-bold uppercase opacity-50 tracking-tight leading-none">
{label}
</span>
<span className={cn(
"mt-1 truncate w-full text-xs",
value ? "font-semibold text-primary" : "text-muted-foreground/70"
)}>
{value || t('search_more.any')}
</span>
</div>
<div className="flex items-center gap-1.5">
{value && (
<div
onClick={(e) => { e.stopPropagation(); onReset(); }}
className="p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</div>
)}
</div>
{value && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0 w-64 shadow-xl">
<Command shouldFilter={false}>
<CommandInput
placeholder={t('search_more.search_placeholder', { field: label })}
value={searchTerm}
onValueChange={setSearchTerm}
className="h-8"
/>
<CommandList className="max-h-[240px]">
{isLoading && <div className="p-4 text-[10px] text-center opacity-50">{t('common.loading')}</div>}
<CommandEmpty className="text-[10px] p-2 text-center">{t('common.noData')}</CommandEmpty>
<CommandGroup>
{filteredOptions.map((opt) => (
<CommandItem
key={opt}
onSelect={() => {
value === opt ? onReset() : onSelect(opt);
}}
className="flex items-center justify-between py-2 px-3 cursor-pointer text-xs"
>
<span className="truncate">{opt}</span>
{value === opt && <Check className="h-3 w-3 text-primary shrink-0" />}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+1 -1
View File
@@ -66,7 +66,7 @@ export function MailFilterPopover() {
{fields.map((field) => (
<ContactSelectorField
key={field}
label={field}
label={t(`search.${field}`)}
value={filter[field] as string | undefined}
onSelect={(email) => updateFilter(field, email)}
onReset={() => updateFilter(field, undefined)}
@@ -11,6 +11,8 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
import { useAttachmentMetadata } from "@/hooks/use-attachment-metadata"
import { MetadataSelectorField } from "./attachment-metadata-selector"
const SIZES = {
tiny: { min: undefined, max: 15 * 1024 },
@@ -34,8 +36,13 @@ export function MoreFiltersPopover() {
const { filter, setFilter } = useSearchContext();
const [open, setOpen] = React.useState(false);
const { data: meta, isLoading: metaLoading } = useAttachmentMetadata();
const [localState, setLocalState] = React.useState({
attachment_name: filter?.attachment_name || '',
attachment_extension: filter?.attachment_extension || '',
attachment_category: filter?.attachment_category || '',
attachment_content_type: filter?.attachment_content_type || '',
message_id: filter?.message_id || '',
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
has_attachment: filter?.has_attachment || false
@@ -45,6 +52,9 @@ export function MoreFiltersPopover() {
if (open) {
setLocalState({
attachment_name: filter?.attachment_name || '',
attachment_extension: filter?.attachment_extension || '',
attachment_category: filter?.attachment_category || '',
attachment_content_type: filter?.attachment_content_type || '',
message_id: filter?.message_id || '',
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
has_attachment: filter?.has_attachment || false
@@ -59,6 +69,15 @@ export function MoreFiltersPopover() {
if (localState.attachment_name) next.attachment_name = localState.attachment_name;
else delete next.attachment_name;
if (localState.attachment_extension) next.attachment_extension = localState.attachment_extension;
else delete next.attachment_extension;
if (localState.attachment_category) next.attachment_category = localState.attachment_category;
else delete next.attachment_category;
if (localState.attachment_content_type) next.attachment_content_type = localState.attachment_content_type;
else delete next.attachment_content_type;
if (localState.message_id) next.message_id = localState.message_id;
else delete next.message_id;
@@ -79,7 +98,10 @@ export function MoreFiltersPopover() {
filter?.min_size,
filter?.max_size,
filter?.message_id,
filter?.has_attachment
filter?.has_attachment,
filter?.attachment_extension,
filter?.attachment_category,
filter?.attachment_content_type
].filter(Boolean).length;
return (
@@ -118,6 +140,9 @@ export function MoreFiltersPopover() {
delete next.max_size;
delete next.message_id;
delete next.has_attachment;
delete next.attachment_extension;
delete next.attachment_category;
delete next.attachment_content_type;
return next;
});
setOpen(false);
@@ -132,9 +157,19 @@ export function MoreFiltersPopover() {
<Checkbox
id="has_attachment"
checked={localState.has_attachment}
onCheckedChange={(checked) =>
setLocalState(prev => ({ ...prev, has_attachment: checked as boolean }))
}
onCheckedChange={(checked) => {
const isChecked = checked as boolean;
setLocalState(prev => ({
...prev,
has_attachment: isChecked,
...(isChecked ? {} : {
attachment_name: '',
attachment_extension: '',
attachment_category: '',
attachment_content_type: ''
})
}));
}}
/>
<Label
htmlFor="has_attachment"
@@ -144,15 +179,46 @@ export function MoreFiltersPopover() {
</Label>
</div>
<div className="space-y-2">
<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={t('search_more.attachment_name_placeholder')}
/>
</div>
{localState.has_attachment && (
<div className="space-y-3 p-2 bg-muted/30 rounded-lg border border-dashed border-border animate-in fade-in slide-in-from-top-1">
<MetadataSelectorField
label={t('search_more.extension')}
value={localState.attachment_extension}
options={meta?.extensions || []}
isLoading={metaLoading}
onSelect={(v) => setLocalState(p => ({ ...p, attachment_extension: v, attachment_category: '', attachment_content_type: '' }))}
onReset={() => setLocalState(p => ({ ...p, attachment_extension: '' }))}
/>
<MetadataSelectorField
label={t('search_more.category')}
value={localState.attachment_category}
options={meta?.categories || []}
isLoading={metaLoading}
onSelect={(v) => setLocalState(p => ({ ...p, attachment_category: v, attachment_extension: '', attachment_content_type: '' }))}
onReset={() => setLocalState(p => ({ ...p, attachment_category: '' }))}
/>
<MetadataSelectorField
label={t('search_more.content_types')}
value={localState.attachment_content_type}
options={meta?.content_types || []}
isLoading={metaLoading}
onSelect={(v) => setLocalState(p => ({ ...p, attachment_content_type: v, attachment_extension: '', attachment_category: '' }))}
onReset={() => setLocalState(p => ({ ...p, attachment_content_type: '' }))}
/>
<div className="space-y-1 px-1">
<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={t('search_more.attachment_name_placeholder')}
/>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('search_more.message_size_label')}</Label>