mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(search-ui): optimize search UI
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { AccountPopover } from './account-popover'
|
||||
import { MailboxPopover } from './mailbox-popover'
|
||||
|
||||
export function AccountMailboxFilter() {
|
||||
return (
|
||||
<>
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import * as React from 'react'
|
||||
import { AtSign, ChevronDown, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
export function AccountPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [search, setSearch] = React.useState('')
|
||||
const { minimalList = [] } = useMinimalAccountList()
|
||||
|
||||
const selectedIds: number[] = filter.account_ids ?? []
|
||||
|
||||
const toggleAccount = (id: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const set = new Set<number>(next.account_ids ?? [])
|
||||
|
||||
if (set.has(id)) {
|
||||
set.delete(id)
|
||||
} else {
|
||||
set.add(id)
|
||||
}
|
||||
|
||||
if (set.size === 0) {
|
||||
delete next.account_ids
|
||||
delete next.mailbox_ids
|
||||
} else {
|
||||
next.account_ids = Array.from(set).sort()
|
||||
delete next.mailbox_ids
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAccounts = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.account_ids
|
||||
delete next.mailbox_ids
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
|
||||
return minimalList
|
||||
.filter(a =>
|
||||
!q ||
|
||||
a.email.toLowerCase().includes(q) ||
|
||||
String(a.id).includes(q)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aSel = selectedIds.includes(a.id)
|
||||
const bSel = selectedIds.includes(b.id)
|
||||
|
||||
if (aSel && !bSel) return -1
|
||||
if (!aSel && bSel) return 1
|
||||
return a.id - b.id
|
||||
})
|
||||
}, [minimalList, search, selectedIds])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 gap-1.5 px-3 rounded-none',
|
||||
selectedIds.length > 0 &&
|
||||
'bg-primary/10 border-primary text-primary'
|
||||
)}
|
||||
>
|
||||
<AtSign className="h-4 w-4" />
|
||||
Account
|
||||
{selectedIds.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-5 px-1.5 text-xs"
|
||||
>
|
||||
{selectedIds.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="w-96 p-1">
|
||||
<div className="p-1 pb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('search.searchAccount')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{!search && selectedIds.length > 0 && (
|
||||
<div className="p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAccounts}
|
||||
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"
|
||||
>
|
||||
<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.clearAccounts')}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60 font-mono">
|
||||
({selectedIds.length})
|
||||
</span>
|
||||
</Button>
|
||||
<div className="my-1 h-px bg-border/60" />
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Paperclip, Check } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchContext } from './context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AttachmentFilter() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
|
||||
const hasAttachment = filter?.has_attachment === true
|
||||
|
||||
const toggleAttachment = () => {
|
||||
setFilter((prev) => {
|
||||
const next = { ...prev }
|
||||
if (next.has_attachment) {
|
||||
delete next.has_attachment
|
||||
} else {
|
||||
next.has_attachment = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={toggleAttachment}
|
||||
className={cn(
|
||||
"h-8 px-3 gap-2 transition-all rounded-none flex-shrink-0",
|
||||
hasAttachment
|
||||
? "bg-primary/10 border-primary text-primary hover:bg-primary/20 hover:text-primary z-10"
|
||||
: "text-muted-foreground border-r-0"
|
||||
)}
|
||||
>
|
||||
<Paperclip
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
hasAttachment ? "opacity-100" : "opacity-60"
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="text-xs font-medium">
|
||||
{t('mail.attachments')}
|
||||
</span>
|
||||
|
||||
{hasAttachment && (
|
||||
<Check className="h-3 w-3 ml-0.5 stroke-[3px] animate-in zoom-in duration-200" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +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 { useTranslation } from 'react-i18next'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { SquarePen } from 'lucide-react'
|
||||
import { Button } from '@/components/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { useState } from 'react'
|
||||
import { useSearchContext } from './context'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const defaultColumns = (t: (key: string) => string) => [
|
||||
{
|
||||
label: t('search.account'),
|
||||
value: "account_email"
|
||||
},
|
||||
{
|
||||
label: t('search.mailbox'),
|
||||
value: "mailbox_name"
|
||||
},
|
||||
{
|
||||
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"
|
||||
},
|
||||
]
|
||||
|
||||
export function ColumnsDialog({ open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { setColumnVisibility } = useSearchContext()
|
||||
const columns = defaultColumns(t)
|
||||
|
||||
const [selected, setSelected] = useState(() => {
|
||||
const _columns = localStorage.getItem("searchTableColumns")
|
||||
? JSON.parse(localStorage.getItem("searchTableColumns") as string) as Record<string, boolean>
|
||||
: undefined
|
||||
|
||||
if (_columns) return new Map(Object.entries(_columns).map(([key, value]) => [key, value]))
|
||||
return new Map(columns.map((col) => [col.value, true]))
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
const _selected = Object.fromEntries(selected)
|
||||
setColumnVisibility(_selected)
|
||||
localStorage.setItem("searchTableColumns", JSON.stringify(_selected))
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const toggleSelected = (column: string) => {
|
||||
setSelected(prev => {
|
||||
const value = new Map(prev)
|
||||
|
||||
if (value.get(column)) {
|
||||
value.set(column, false)
|
||||
} else {
|
||||
value.set(column, true)
|
||||
}
|
||||
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SquarePen className="h-5 w-5" />
|
||||
{t('common.columns')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="flex flex-wrap flex-col gap-2">
|
||||
{columns.map(col => (
|
||||
<div key={col.value} className="flex flex-row gap-2 items-center">
|
||||
<Checkbox
|
||||
id={col.value}
|
||||
checked={selected.get(col.value)}
|
||||
onCheckedChange={() => toggleSelected(col.value)}
|
||||
/>
|
||||
<Label htmlFor={col.value} className="cursor-pointer text-sm font-normal">
|
||||
{col.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end items-center">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('search.addTags.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{t('search.addTags.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { useSearchContext } from "./context"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check, ChevronDown, Mail, X } from "lucide-react"
|
||||
import React from "react"
|
||||
import { useContacts } from "@/hooks/use-contacts"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
|
||||
export function MailFilterPopover() {
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const fields = ['from', 'to', 'cc', 'bcc'] as const
|
||||
|
||||
const activeCount = fields.filter(k => !!filter[k]).length
|
||||
|
||||
const updateFilter = (field: string, email: string | undefined) => {
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
[field]: email
|
||||
}))
|
||||
}
|
||||
|
||||
const resetAll = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
fields.forEach(k => delete next[k])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5 transition-colors',
|
||||
activeCount > 0 && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 opacity-60" />
|
||||
<span>{activeCount > 0 ? `Participants (${activeCount})` : 'Participants'}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-fit min-w-[280px] max-w-[90vw] sm:max-w-[min(90vw,500px)] p-0 flex flex-col divide-y divide-border shadow-xl"
|
||||
>
|
||||
<div className="flex flex-col bg-muted/20 divide-y divide-border">
|
||||
{fields.map((field) => (
|
||||
<ContactSelectorField
|
||||
key={field}
|
||||
label={field}
|
||||
value={filter[field] as string | undefined}
|
||||
onSelect={(email) => updateFilter(field, email)}
|
||||
onReset={() => updateFilter(field, undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeCount > 0 && (
|
||||
<div className="p-2 flex justify-end bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs font-medium text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={resetAll}
|
||||
>
|
||||
Reset All Participants
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function ContactSelectorField({
|
||||
label,
|
||||
value,
|
||||
onSelect,
|
||||
onReset
|
||||
}: {
|
||||
label: string
|
||||
value?: string
|
||||
onSelect: (email: string | undefined) => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
const { contacts, isLoading } = useContacts(searchTerm)
|
||||
|
||||
const handleToggle = (email: string) => {
|
||||
if (value === email) {
|
||||
onReset()
|
||||
} else {
|
||||
onSelect(email)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group flex items-center justify-between w-full px-4 py-3 hover:bg-background transition-all text-left relative",
|
||||
"min-h-[52px]",
|
||||
value && "bg-background/60 hover:bg-background/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start pr-6">
|
||||
<span className="text-[10px] font-bold uppercase opacity-50 tracking-tight leading-none">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 truncate max-w-[320px]",
|
||||
value
|
||||
? "text-xs font-semibold text-primary"
|
||||
: "text-xs text-muted-foreground/90"
|
||||
)}
|
||||
>
|
||||
{value || 'Any'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReset()
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="p-0 w-auto min-w-[300px] max-w-[420px] shadow-2xl border-border/50"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={`Search ${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>
|
||||
)}
|
||||
<CommandEmpty>No contact found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{contacts.slice(0, 100).map((email) => (
|
||||
<CommandItem
|
||||
key={email}
|
||||
onSelect={() => handleToggle(email)}
|
||||
className="flex items-center justify-between py-2.5 px-3 cursor-pointer whitespace-nowrap gap-4 text-xs"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium">
|
||||
{email.split('@')[0]}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[360px]">
|
||||
{email}
|
||||
</span>
|
||||
</div>
|
||||
{value === email && (
|
||||
<Check className="h-4 w-4 text-primary shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -19,9 +19,9 @@
|
||||
|
||||
import React from 'react'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { SortingState, VisibilityState } from '@tanstack/react-table'
|
||||
import { SortingState } from '@tanstack/react-table'
|
||||
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore' | 'columns'
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore'
|
||||
|
||||
interface SearchContextType {
|
||||
open: SearchDialogType | null
|
||||
@@ -35,8 +35,9 @@ interface SearchContextType {
|
||||
selectedTags: string[]
|
||||
sorting: SortingState
|
||||
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
|
||||
columnVisibility: VisibilityState
|
||||
setColumnVisibility: React.Dispatch<React.SetStateAction<VisibilityState>>
|
||||
filter: Record<string, any>
|
||||
setFilter: React.Dispatch<React.SetStateAction<Record<string, any>>>
|
||||
handleTagToggle: (tag: string) => void
|
||||
}
|
||||
|
||||
const SearchContext = React.createContext<SearchContextType | null>(null)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useSearchContext } from "./context"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function FilterResetButton() {
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
|
||||
const { q, ...restFilters } = filter;
|
||||
const activeFiltersCount = Object.keys(restFilters).filter(key => {
|
||||
const value = restFilters[key];
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
}).length;
|
||||
|
||||
if (activeFiltersCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFilter(q ? { q } : {})}
|
||||
className={cn(
|
||||
"h-8 px-2 text-xs gap-1.5 font-normal",
|
||||
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
)}
|
||||
>
|
||||
<span>Reset</span>
|
||||
<div className="flex items-center justify-center w-4 h-4 rounded-full bg-muted-foreground/20 text-[10px]">
|
||||
{activeFiltersCount}
|
||||
</div>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -25,12 +25,11 @@ import { SearchFormDialog } from './search-form';
|
||||
import { EnvelopeListPagination } from '@/components/pagination';
|
||||
import React from 'react';
|
||||
import { EmailEnvelope } from '@/api';
|
||||
import { Filter, SearchIcon, SquarePen } from 'lucide-react';
|
||||
import { Filter, SearchIcon } from 'lucide-react';
|
||||
import { MailDisplayDrawer } from './mail-display-dialog';
|
||||
import { EnvelopeDeleteDialog } from './delete-dialog';
|
||||
import SearchProvider, { SearchDialogType } from './context';
|
||||
import useDialogState from '@/hooks/use-dialog-state';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EnvelopeTags } from './tag-facet';
|
||||
@@ -38,9 +37,8 @@ import { EditTagsDialog } from './add-tag-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import { RestoreMessageDialog } from './restore-message-dialog';
|
||||
import { ColumnsDialog } from './columns-dialog';
|
||||
import { MailListTable } from './mail-list-table';
|
||||
import { SortingState, VisibilityState } from '@tanstack/react-table';
|
||||
import { SortingState } from '@tanstack/react-table';
|
||||
|
||||
export default function Search() {
|
||||
const { t } = useTranslation()
|
||||
@@ -50,10 +48,6 @@ export default function Search() {
|
||||
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
|
||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(localStorage.getItem("searchTableColumns")
|
||||
? JSON.parse(localStorage.getItem("searchTableColumns") as string) as Record<string, boolean>
|
||||
: {}
|
||||
)
|
||||
|
||||
const {
|
||||
emails,
|
||||
@@ -69,7 +63,8 @@ export default function Search() {
|
||||
setSortOrder,
|
||||
onSubmit,
|
||||
reset,
|
||||
filter
|
||||
filter,
|
||||
setFilter
|
||||
} = useSearchMessages();
|
||||
|
||||
const handleSetPageSize = (pageSize: number) => {
|
||||
@@ -96,7 +91,7 @@ export default function Search() {
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<SearchProvider
|
||||
value={{
|
||||
value={{
|
||||
open,
|
||||
setOpen,
|
||||
currentEnvelope: selectedEnvelope,
|
||||
@@ -108,64 +103,22 @@ export default function Search() {
|
||||
setSelected,
|
||||
sorting,
|
||||
setSorting,
|
||||
columnVisibility,
|
||||
setColumnVisibility
|
||||
filter,
|
||||
setFilter,
|
||||
handleTagToggle
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full px-4">
|
||||
<div className="mb-4 lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
{t('search.tagFilter')}
|
||||
{selectedTags.length > 0 && ` (${selectedTags.length})`}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-80">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('search.tagFilter')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6">
|
||||
<EnvelopeTags
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
{/* <aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<EnvelopeTags
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</aside> */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
<div className="flex flex-row gap-4 items-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => setOpen("search-form")}
|
||||
className="px-4 shadow-sm"
|
||||
>
|
||||
<SearchIcon className="mr-2 h-4 w-4" />
|
||||
{t('common.search')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => setOpen("columns")}
|
||||
className="px-4 shadow-sm"
|
||||
>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
{t('common.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
@@ -177,7 +130,7 @@ export default function Search() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{total === 0 && <div className="flex h-[750px] shrink-0 items-center justify-center rounded-md border border-dashed">
|
||||
{/* {!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}
|
||||
@@ -191,19 +144,17 @@ export default function Search() {
|
||||
: t('search.adjustSearch')}
|
||||
</p>
|
||||
</div>
|
||||
</div>}
|
||||
{total > 0 && <ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1' orientation='both'>
|
||||
<MailListTable
|
||||
isLoading={isLoading}
|
||||
items={emails}
|
||||
onEnvelopeChanged={(envelope) => {
|
||||
setOpen('display');
|
||||
setSelectedEnvelope(envelope);
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
/>
|
||||
</ScrollArea>}
|
||||
</div>} */}
|
||||
<MailListTable
|
||||
isLoading={isLoading}
|
||||
items={emails}
|
||||
onEnvelopeChanged={(envelope) => {
|
||||
setOpen('display');
|
||||
setSelectedEnvelope(envelope);
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
/>
|
||||
{total > 0 && <EnvelopeListPagination
|
||||
totalItems={total}
|
||||
hasNextPage={() => page < totalPages}
|
||||
@@ -246,14 +197,7 @@ export default function Search() {
|
||||
open={open === 'restore'}
|
||||
onOpenChange={() => setOpen('restore')}
|
||||
/>
|
||||
|
||||
<ColumnsDialog
|
||||
key='columns-dialog'
|
||||
open={open === 'columns'}
|
||||
onOpenChange={() => setOpen('columns')}
|
||||
/>
|
||||
|
||||
</SearchProvider>
|
||||
</SearchProvider>
|
||||
</Main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { Paperclip } from "lucide-react"
|
||||
import { Badge, MessageSquareText, Paperclip } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { EmailEnvelope } from "@/api"
|
||||
@@ -32,208 +32,302 @@ import LongText from "@/components/long-text"
|
||||
import { DataTableColumnHeader } from "./table/data-table-column-header"
|
||||
import { SearchTable } from "./table/table"
|
||||
import { DataTableRowActions } from "./table/data-table-row-actions"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { DataTableToolbar } from "./table/toolbar"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
isLoading: boolean
|
||||
onEnvelopeChanged: (envelope: EmailEnvelope) => void
|
||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||
setSortOrder: (value: "desc" | "asc") => void
|
||||
items: EmailEnvelope[]
|
||||
isLoading: boolean
|
||||
onEnvelopeChanged: (envelope: EmailEnvelope) => void
|
||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||
setSortOrder: (value: "desc" | "asc") => void
|
||||
}
|
||||
|
||||
export function MailListTable({
|
||||
items,
|
||||
isLoading,
|
||||
onEnvelopeChanged,
|
||||
setSortBy,
|
||||
setSortOrder
|
||||
items,
|
||||
isLoading,
|
||||
onEnvelopeChanged,
|
||||
setSortBy,
|
||||
setSortOrder
|
||||
}: MailListProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS
|
||||
const { selected, setSelected } = useSearchContext()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS
|
||||
const { selected, setSelected } = useSearchContext()
|
||||
|
||||
const columns: ColumnDef<EmailEnvelope>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: () => (
|
||||
<Checkbox
|
||||
checked={
|
||||
totalSelected === items.length && items.length > 0
|
||||
? true
|
||||
: totalSelected > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleToggleAll}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={hasSelected(row.original.account_id, row.original.id)}
|
||||
onCheckedChange={() => toggleSelected(row.original.account_id, row.original.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
),
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 25,
|
||||
maxSize: 25,
|
||||
},
|
||||
{
|
||||
accessorKey: "account_email",
|
||||
header: t('search.account'),
|
||||
cell: ({ row }) => <LongText className='text-xs max-w-[150px]'>{row.original.account_email}</LongText>,
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 166
|
||||
},
|
||||
{
|
||||
accessorKey: "mailbox_name",
|
||||
header: t('search.mailbox'),
|
||||
cell: ({ row }) => <LongText className='text-xs max-w-[100px]'>{row.original.mailbox_name}</LongText>,
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 116,
|
||||
maxSize: 116,
|
||||
},
|
||||
{
|
||||
accessorKey: "from",
|
||||
header: t('search.from'),
|
||||
cell: ({ row }) => <LongText className='text-xs max-w-[134px]'>{row.original.from}</LongText>,
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 150,
|
||||
},
|
||||
{
|
||||
accessorKey: "to",
|
||||
header: t('search.to'),
|
||||
cell: ({ row }) => <LongText className='text-xs max-w-[180px]'>{row.original.to.join(", ")}</LongText>,
|
||||
meta: { className: 'text-left text-sm' },
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: t('search.subject'),
|
||||
cell: ({ row }) => <LongText className='text-xs max-w-[500px]'>{row.original.subject}</LongText>,
|
||||
meta: { className: 'text-left text-sm' },
|
||||
size: 1000
|
||||
},
|
||||
{
|
||||
id: "attachment_count",
|
||||
header: () => <Paperclip size={16} />,
|
||||
cell: ({ row }) => <span className='text-xs'>{(row.original.attachments ?? []).length}</span>,
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 40,
|
||||
maxSize: 40
|
||||
},
|
||||
{
|
||||
accessorKey: 'size',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('search.size')} />
|
||||
),
|
||||
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('search.date')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.date)
|
||||
const title = format(date, 'yyyy-MM-dd HH:mm:ss')
|
||||
return (
|
||||
const columns: ColumnDef<EmailEnvelope>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: () => (
|
||||
<Checkbox
|
||||
checked={
|
||||
totalSelected === items.length && items.length > 0
|
||||
? true
|
||||
: totalSelected > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleToggleAll}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={hasSelected(row.original.account_id, row.original.id)}
|
||||
onCheckedChange={() => toggleSelected(row.original.account_id, row.original.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
),
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 25,
|
||||
maxSize: 25,
|
||||
},
|
||||
{
|
||||
accessorKey: "account_email",
|
||||
header: t('search.account'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.account_email}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 156,
|
||||
},
|
||||
{
|
||||
accessorKey: "mailbox_name",
|
||||
header: t('search.mailbox'),
|
||||
cell: ({ row }) => {
|
||||
const mailbox = row.original.mailbox_name
|
||||
const tags = row.original.tags ?? []
|
||||
|
||||
if (!mailbox) return null
|
||||
|
||||
const visible = tags.slice(0, 3)
|
||||
const rest = tags.length - visible.length
|
||||
|
||||
const fullTags = tags.join(' · ')
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='text-xs whitespace-nowrap'>
|
||||
{formatDistanceToNow(date, { addSuffix: true, locale })}
|
||||
</span>
|
||||
<div className="flex flex-col leading-tight max-w-[130px] cursor-default">
|
||||
<span className="text-xs truncate">
|
||||
{mailbox}
|
||||
</span>
|
||||
|
||||
{visible.length > 0 && (
|
||||
<span className="text-[10px] text-primary/80 truncate">
|
||||
{visible.join(' · ')}
|
||||
{rest > 0 && ` · +${rest}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{title}</TooltipContent>
|
||||
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="max-w-xs"
|
||||
>
|
||||
<div className="text-xs font-medium mb-1">
|
||||
{mailbox}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground break-words">
|
||||
{fullTags}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 100,
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('users.columns.actions'),
|
||||
cell: DataTableRowActions,
|
||||
minSize: 70,
|
||||
maxSize: 70,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 116,
|
||||
maxSize: 116,
|
||||
},
|
||||
{
|
||||
accessorKey: "from",
|
||||
header: t('search.from'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.from}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 156,
|
||||
},
|
||||
{
|
||||
accessorKey: "to",
|
||||
header: t('search.to'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.to.join(", ")}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 156,
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: t('search.subject'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 450,
|
||||
maxSize: 456,
|
||||
},
|
||||
{
|
||||
id: "text_preview",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const text = row.original.text
|
||||
|
||||
if (!text) return null
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200} closeDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-primary transition"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MessageSquareText size={16} />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="max-w-[520px] max-h-[420px] overflow-auto whitespace-pre-wrap text-xs leading-relaxed"
|
||||
>
|
||||
{text}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
},
|
||||
]
|
||||
meta: { className: "text-center max-w-[80px]" },
|
||||
minSize: 36,
|
||||
maxSize: 36,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "attachment_count",
|
||||
header: () => <Paperclip size={16} />,
|
||||
cell: ({ row }) => <span className='text-xs'>{(row.original.attachments ?? []).length}</span>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 40,
|
||||
maxSize: 40
|
||||
},
|
||||
{
|
||||
accessorKey: 'size',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('search.size')} />
|
||||
),
|
||||
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('search.date')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.date)
|
||||
const title = format(date, 'yyyy-MM-dd HH:mm:ss')
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='text-xs whitespace-nowrap'>
|
||||
{formatDistanceToNow(date, { addSuffix: true, locale })}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{title}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('users.columns.actions'),
|
||||
cell: DataTableRowActions,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 50,
|
||||
maxSize: 60,
|
||||
},
|
||||
]
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const total = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
|
||||
const handleToggleAll = () => {
|
||||
const total = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
|
||||
|
||||
if (total === items.length && items.length > 0) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
for (const item of items) {
|
||||
const set = new Set(next.get(item.account_id) || [])
|
||||
set.add(item.id)
|
||||
next.set(item.account_id, set)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelected = (accountId: number, mailId: number) => {
|
||||
if (total === items.length && items.length > 0) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
const set = new Set(next.get(accountId) || [])
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId)
|
||||
if (set.size === 0) next.delete(accountId)
|
||||
else next.set(accountId, set)
|
||||
} else {
|
||||
set.add(mailId)
|
||||
next.set(accountId, set)
|
||||
for (const item of items) {
|
||||
const set = new Set(next.get(item.account_id) || [])
|
||||
set.add(item.id)
|
||||
next.set(item.account_id, set)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totalSelected = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
|
||||
const toggleSelected = (accountId: number, mailId: number) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
const set = new Set(next.get(accountId) || [])
|
||||
|
||||
const hasSelected = (accountId: number, mailId: number) => selected.get(accountId)?.has(mailId) ?? false
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId)
|
||||
if (set.size === 0) next.delete(accountId)
|
||||
else next.set(accountId, set)
|
||||
} else {
|
||||
set.add(mailId)
|
||||
next.set(accountId, set)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Skeleton className="h-3 w-3" />
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-2.5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const totalSelected = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
|
||||
|
||||
const hasSelected = (accountId: number, mailId: number) => selected.get(accountId)?.has(mailId) ?? false
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<SearchTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
onRowClick={(e, row) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input[type="checkbox"], button')) return
|
||||
onEnvelopeChanged(row.original)
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
/>
|
||||
{totalSelected > 0 && <MailBulkActions />}
|
||||
</>
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Skeleton className="h-3 w-3" />
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-2.5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
onRowClick={(e, row) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input[type="checkbox"], button')) return
|
||||
onEnvelopeChanged(row.original)
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
>
|
||||
{(table) => {
|
||||
return <DataTableToolbar table={table} />
|
||||
}}
|
||||
|
||||
</SearchTable>
|
||||
{totalSelected > 0 && <MailBulkActions />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronDown, Folders, X } from 'lucide-react'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
export function MailboxPopover() {
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { minimalList = [] } = useMinimalAccountList()
|
||||
|
||||
const [search, setSearch] = React.useState('')
|
||||
|
||||
const accountIds: number[] = filter.account_ids ?? []
|
||||
const selectedMailboxIds: number[] = filter.mailbox_ids ?? []
|
||||
|
||||
const { mailboxes, isLoading } = useQueries({
|
||||
queries: accountIds.map(id => ({
|
||||
queryKey: ['search-mailboxes', id],
|
||||
queryFn: () => list_mailboxes(id, false),
|
||||
enabled: accountIds.length > 0,
|
||||
})),
|
||||
combine: results => ({
|
||||
mailboxes: results.flatMap(r => r.data ?? []),
|
||||
isLoading: results.some(r => r.isLoading),
|
||||
}),
|
||||
})
|
||||
|
||||
const toggleMailbox = (id: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const set = new Set<number>(next.mailbox_ids ?? [])
|
||||
|
||||
set.has(id) ? set.delete(id) : set.add(id)
|
||||
|
||||
const ids = Array.from(set)
|
||||
|
||||
if (ids.length === 0) delete next.mailbox_ids
|
||||
else next.mailbox_ids = ids
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAllMailboxes = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.mailbox_ids
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const grouped = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const map = new Map<number, MailboxData[]>()
|
||||
|
||||
for (const mb of mailboxes) {
|
||||
if (q && !mb.name.toLowerCase().includes(q)) continue
|
||||
if (!map.has(mb.account_id)) map.set(mb.account_id, [])
|
||||
map.get(mb.account_id)!.push(mb)
|
||||
}
|
||||
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => {
|
||||
const aSel = selectedMailboxIds.includes(a.id)
|
||||
const bSel = selectedMailboxIds.includes(b.id)
|
||||
if (aSel && !bSel) return -1
|
||||
if (!aSel && bSel) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
}, [mailboxes, search, selectedMailboxIds])
|
||||
|
||||
const defaultOpen = grouped
|
||||
.filter(([, boxes]) =>
|
||||
boxes.some(m => selectedMailboxIds.includes(m.id))
|
||||
)
|
||||
.map(([id]) => id.toString())
|
||||
|
||||
const getAccountEmail = (id: number) =>
|
||||
minimalList.find(a => a.id === id)?.email ?? ''
|
||||
|
||||
const disabled = accountIds.length === 0
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5',
|
||||
selectedMailboxIds.length > 0 &&
|
||||
'bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<Folders className="h-4 w-4" />
|
||||
Mailbox
|
||||
{selectedMailboxIds.length > 0 && (
|
||||
<span className="ml-1 text-xs opacity-70">
|
||||
{selectedMailboxIds.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="min-w-[260px] w-fit max-w-[620px] p-1">
|
||||
<div className="p-1 pb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search mailbox"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{selectedMailboxIds.length > 0 && (
|
||||
<div className="px-1 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllMailboxes}
|
||||
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})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{disabled ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
Please select account first
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<div className="space-y-2 p-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 rounded bg-muted animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : grouped.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
No mailbox found
|
||||
</p>
|
||||
) : (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={defaultOpen}
|
||||
className="space-y-1"
|
||||
>
|
||||
{grouped.map(([accountId, boxes]) => {
|
||||
const selectedCount = boxes.filter(b =>
|
||||
selectedMailboxIds.includes(b.id)
|
||||
).length
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
key={accountId}
|
||||
value={accountId.toString()}
|
||||
>
|
||||
<AccordionTrigger className="text-xs px-2 py-1.5">
|
||||
<span className="truncate">
|
||||
{getAccountEmail(accountId)}
|
||||
</span>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<span className="ml-2 text-[10px] text-primary">
|
||||
{selectedCount}
|
||||
</span>
|
||||
)}
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent>
|
||||
<div className="space-y-0.5">
|
||||
{boxes.map(mailbox => {
|
||||
const checked =
|
||||
selectedMailboxIds.includes(mailbox.id)
|
||||
|
||||
return (
|
||||
<TooltipProvider key={mailbox.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() =>
|
||||
toggleMailbox(mailbox.id)
|
||||
}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
||||
'hover:bg-accent transition-colors',
|
||||
checked &&
|
||||
'bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
toggleMailbox(mailbox.id)
|
||||
}
|
||||
onClick={e =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
/>
|
||||
|
||||
<span className="text-xs truncate">
|
||||
{mailbox.name}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="right">
|
||||
<div className="text-sm break-all">
|
||||
{mailbox.name}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import * as React from "react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ListFilter } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchContext } from "./context"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SIZES = {
|
||||
tiny: { min: undefined, max: 15 * 1024 },
|
||||
small: { min: undefined, max: 2 * 1024 * 1024 },
|
||||
medium: { min: 2 * 1024 * 1024, max: 10 * 1024 * 1024 },
|
||||
large: { min: 10 * 1024 * 1024, max: 20 * 1024 * 1024 },
|
||||
huge: { min: 20 * 1024 * 1024, max: undefined },
|
||||
};
|
||||
|
||||
const getPresetFromSize = (min?: number, max?: number) => {
|
||||
if (min === SIZES.huge.min) return 'huge';
|
||||
if (min === SIZES.large.min && max === SIZES.large.max) return 'large';
|
||||
if (min === SIZES.medium.min && max === SIZES.medium.max) return 'medium';
|
||||
if (!min && max === SIZES.small.max) return 'small';
|
||||
if (!min && max === SIZES.tiny.max) return 'tiny';
|
||||
return 'any';
|
||||
};
|
||||
|
||||
export function MoreFiltersPopover() {
|
||||
const { t } = useTranslation();
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [localState, setLocalState] = React.useState({
|
||||
attachment_name: filter?.attachment_name || '',
|
||||
message_id: filter?.message_id || '',
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
has_attachment: filter?.has_attachment || false
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setLocalState({
|
||||
attachment_name: filter?.attachment_name || '',
|
||||
message_id: filter?.message_id || '',
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
has_attachment: filter?.has_attachment || false
|
||||
});
|
||||
}
|
||||
}, [open, filter]);
|
||||
|
||||
const handleApply = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev };
|
||||
|
||||
if (localState.attachment_name) next.attachment_name = localState.attachment_name;
|
||||
else delete next.attachment_name;
|
||||
|
||||
if (localState.message_id) next.message_id = localState.message_id;
|
||||
else delete next.message_id;
|
||||
|
||||
if (localState.has_attachment) next.has_attachment = true;
|
||||
else delete next.has_attachment;
|
||||
|
||||
const range = SIZES[localState.size_preset as keyof typeof SIZES] || { min: undefined, max: undefined };
|
||||
if (range.min) next.min_size = range.min; else delete next.min_size;
|
||||
if (range.max) next.max_size = range.max; else delete next.max_size;
|
||||
|
||||
return next;
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const activeCount = [
|
||||
filter?.attachment_name,
|
||||
filter?.min_size,
|
||||
filter?.max_size,
|
||||
filter?.message_id,
|
||||
filter?.has_attachment
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-2 px-3 rounded-none border-l-0",
|
||||
activeCount > 0 && "bg-primary/10 border-primary text-primary"
|
||||
)}
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">Advanced</span>
|
||||
{activeCount > 0 && (
|
||||
<Badge className="ml-1 h-4 px-1 text-[10px] bg-primary text-primary-foreground border-none rounded-sm">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<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>
|
||||
{activeCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 text-[10px] text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev };
|
||||
delete next.attachment_name;
|
||||
delete next.min_size;
|
||||
delete next.max_size;
|
||||
delete next.message_id;
|
||||
delete next.has_attachment;
|
||||
return next;
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center space-x-2 px-1">
|
||||
<Checkbox
|
||||
id="has_attachment"
|
||||
checked={localState.has_attachment}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocalState(prev => ({ ...prev, has_attachment: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="has_attachment"
|
||||
className="text-xs font-normal cursor-pointer select-none"
|
||||
>
|
||||
Has Attachments
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Attachment Name</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Message Size</Label>
|
||||
<Select
|
||||
value={localState.size_preset}
|
||||
onValueChange={(v) => setLocalState(prev => ({ ...prev, size_preset: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Original Message ID</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')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button size="sm" className="w-full h-8 text-xs mt-2" onClick={handleApply}>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -388,7 +388,6 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
|
||||
|
||||
<div>
|
||||
{showAdvanced && <Accordion type="multiple" className="space-y-3">
|
||||
{/* Sender & Recipients */}
|
||||
<AccordionItem value="people">
|
||||
<AccordionTrigger className="text-sm">
|
||||
{t('search.sender')} / {t('search.recipient')}
|
||||
|
||||
@@ -31,8 +31,9 @@ import {
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import {
|
||||
Table,
|
||||
Table as ShadcnTable,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
@@ -43,6 +44,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from '../context'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -57,10 +61,11 @@ interface DataTableProps {
|
||||
onRowClick: (e: ReactMouseEvent<HTMLTableRowElement, MouseEvent>, row: Row<EmailEnvelope>) => void
|
||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||
setSortOrder: (value: "desc" | "asc") => void
|
||||
children?: (table: Table<EmailEnvelope>) => React.ReactNode
|
||||
}
|
||||
|
||||
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder }: DataTableProps) {
|
||||
const { sorting, setSorting, columnVisibility, setColumnVisibility } = useSearchContext()
|
||||
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder, children }: DataTableProps) {
|
||||
const { sorting, setSorting } = useSearchContext()
|
||||
const { t } = useTranslation()
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
@@ -76,7 +81,6 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
@@ -84,7 +88,6 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
@@ -93,9 +96,10 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
{children && (<>{children(table)}</>)}
|
||||
<ScrollArea className='h-[calc(100vh-13rem)] rounded-md border' orientation='both'>
|
||||
<ShadcnTable>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className='group/row'>
|
||||
@@ -131,7 +135,7 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cell.column.columnDef.meta?.className ?? ''}
|
||||
style={{
|
||||
style={{
|
||||
width: cell.column.columnDef.size,
|
||||
minWidth: cell.column.columnDef.minSize,
|
||||
maxWidth: cell.column.columnDef.maxSize
|
||||
@@ -155,9 +159,10 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ShadcnTable>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
import { TagFilterPopover } from '../tag-filter-popover'
|
||||
import { AccountMailboxFilter } from '../account-mailbox-filter'
|
||||
import { TimePopover } from '../time-popover'
|
||||
import { MailFilterPopover } from '../contact-popover'
|
||||
import { TextSearchInput } from '../text-search-input'
|
||||
import { MoreFiltersPopover } from '../more-filters-popover'
|
||||
import { FilterResetButton } from '../filter-reset'
|
||||
|
||||
type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1 py-1 lg:flex-row lg:items-center lg:gap-1">
|
||||
<div className="flex-1">
|
||||
<TextSearchInput />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap lg:justify-end">
|
||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap">
|
||||
<TagFilterPopover />
|
||||
<AccountMailboxFilter />
|
||||
<MailFilterPopover />
|
||||
<TimePopover />
|
||||
<MoreFiltersPopover />
|
||||
<FilterResetButton />
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-auto lg:ml-0">
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
|
||||
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type DataTableViewOptionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const defaultColumns = (t: (key: string) => string) => [
|
||||
{ label: t('search.account'), value: "account_email" },
|
||||
{ label: t('search.mailbox'), value: "mailbox_name" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
const columnLabels = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
defaultColumns(t).map(col => [col.value, col.label])
|
||||
)
|
||||
}, [t]);
|
||||
|
||||
|
||||
const visibleColumnKeys = React.useMemo(() => {
|
||||
return new Set(defaultColumns(t).map(c => c.value))
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='ms-auto hidden h-8 lg:flex rounded-none'
|
||||
>
|
||||
<MixerHorizontalIcon className='size-4' />
|
||||
View
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||
<DropdownMenuLabel className='text-xs'>Toggle columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(column => visibleColumnKeys.has(column.id))
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className='capitalize text-xs'
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{columnLabels[column.id] ?? column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as React from 'react'
|
||||
import { Tag, ChevronDown, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
import { useAvailableTags } from '@/hooks/use-available-tags'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
export function TagFilterPopover() {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = React.useState('')
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
|
||||
const selectedTags = (filter?.tags as string[]) || []
|
||||
const {
|
||||
tagsCount = [],
|
||||
isLoading,
|
||||
} = useAvailableTags()
|
||||
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const currentTags = (next.tags as string[]) || []
|
||||
const isSelected = currentTags.includes(tag)
|
||||
|
||||
const nextTags = isSelected
|
||||
? currentTags.filter(t => t !== tag)
|
||||
: [...currentTags, tag]
|
||||
|
||||
if (nextTags.length > 0) {
|
||||
next.tags = nextTags
|
||||
} else {
|
||||
delete next.tags
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAllTags = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.tags
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filteredTags = React.useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
|
||||
return tagsCount
|
||||
.filter(t =>
|
||||
!q || t.tag.toLowerCase().includes(q)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aSelected = selectedTags.includes(a.tag)
|
||||
const bSelected = selectedTags.includes(b.tag)
|
||||
if (aSelected && !bSelected) return -1
|
||||
if (!aSelected && bSelected) return 1
|
||||
return b.count - a.count
|
||||
})
|
||||
}, [tagsCount, search, selectedTags])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 gap-1.5 px-3 rounded-none',
|
||||
selectedTags.length > 0 &&
|
||||
'bg-primary/10 border-primary text-primary'
|
||||
)}
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
{t('mail.tags')}
|
||||
{selectedTags.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-5 px-1.5 text-xs"
|
||||
>
|
||||
{selectedTags.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-96 p-1"
|
||||
>
|
||||
<div className="p-1 pb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('mail.searchTags')}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{!search && selectedTags.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
onClick={clearAllTags}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center">
|
||||
<X className="h-3 w-3" />
|
||||
</div>
|
||||
<span className="flex-1 text-xs font-medium">
|
||||
{t('common.clear_all_tags')}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60">({selectedTags.length})</span>
|
||||
</div>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
</>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 p-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 rounded bg-muted animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filteredTags.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('mail.noTagsFound')}
|
||||
</p>
|
||||
) : (
|
||||
filteredTags.map(({ tag, count }) => {
|
||||
const checked = selectedTags.includes(tag)
|
||||
const id = `tag-${tag}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag}
|
||||
onClick={() => handleTagToggle(tag)}
|
||||
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={() =>
|
||||
handleTagToggle(tag)
|
||||
}
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
/>
|
||||
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 truncate text-xs cursor-pointer"
|
||||
title={tag}
|
||||
>
|
||||
{tag}
|
||||
</Label>
|
||||
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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 { cn } from "@/lib/utils"
|
||||
import { useSearchContext } from "./context"
|
||||
|
||||
const STORAGE_KEY = "mail_search_history"
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
export function TextSearchInput() {
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [value, setValue] = useState(filter.text || "")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
setHistory(JSON.parse(saved))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to load search history", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setValue(filter.text || "")
|
||||
}, [filter.text])
|
||||
|
||||
const saveToHistory = (term: string) => {
|
||||
if (!term.trim()) return
|
||||
|
||||
setHistory(prev => {
|
||||
const trimmed = term.trim()
|
||||
const withoutCurrent = prev.filter(item => item !== trimmed)
|
||||
const newHistory = [trimmed, ...withoutCurrent].slice(0, MAX_HISTORY)
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
|
||||
} catch (err) {
|
||||
console.warn("Failed to save search history", err)
|
||||
}
|
||||
|
||||
return newHistory
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const trimmed = value.trim()
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
text: trimmed || undefined
|
||||
}))
|
||||
if (trimmed) {
|
||||
saveToHistory(trimmed)
|
||||
}
|
||||
setShowHistory(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setValue("")
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.text
|
||||
return next
|
||||
})
|
||||
setShowHistory(false)
|
||||
}
|
||||
|
||||
const handleSelectHistory = (term: string) => {
|
||||
setValue(term)
|
||||
setShowHistory(false)
|
||||
}
|
||||
|
||||
const handleClearHistory = () => {
|
||||
setHistory([])
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear search history", err)
|
||||
}
|
||||
setShowHistory(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowHistory(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const isActive = !!filter.text?.trim()
|
||||
|
||||
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"
|
||||
)}
|
||||
/>
|
||||
{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()}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</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="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
Recent searches
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearHistory}
|
||||
className="text-xs text-destructive hover:text-destructive/80 flex items-center gap-1 hover:underline"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 0 ? (
|
||||
history.map((term, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex items-center gap-2"
|
||||
onClick={() => handleSelectHistory(term)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{term}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-4 text-xs text-center text-muted-foreground">
|
||||
No recent searches
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as React from 'react'
|
||||
import { CalendarRange, ChevronDown, X } from 'lucide-react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
import { DatePicker } from '@/components/date-picker'
|
||||
|
||||
const DAY = 86400000
|
||||
|
||||
export function TimePopover() {
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [customDays, setCustomDays] = React.useState<string>('')
|
||||
|
||||
const since = filter.since
|
||||
const before = filter.before
|
||||
|
||||
const setRange = (s?: number, b?: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
s ? (next.since = s) : delete next.since
|
||||
b ? (next.before = b) : delete next.before
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setSince = (s?: number) => setRange(s, before)
|
||||
const setBefore = (b?: number) => setRange(since, b)
|
||||
|
||||
const handleApplyRecent = () => {
|
||||
const days = parseInt(customDays)
|
||||
if (!isNaN(days) && days > 0) {
|
||||
setRange(Date.now() - days * DAY, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setRange()
|
||||
setCustomDays('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5 transition-colors',
|
||||
(since || before) && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
>
|
||||
<CalendarRange className="h-4 w-4" />
|
||||
{label(since, before)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="w-[530px] p-4 space-y-6">
|
||||
<Section title="Recent Range (Since...)">
|
||||
<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`}
|
||||
</Quick>
|
||||
))}
|
||||
{[3, 6].map(m => (
|
||||
<Quick key={m} onClick={() => setRange(Date.now() - m * 30 * DAY, undefined)}>
|
||||
Last {m} months
|
||||
</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>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="10"
|
||||
className="h-8 w-20 text-xs"
|
||||
value={customDays}
|
||||
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>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 ml-auto text-xs"
|
||||
onClick={handleApplyRecent}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Historical (Older than...)">
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
{[1, 2, 3, 5, 10].map(y => (
|
||||
<Quick
|
||||
key={y}
|
||||
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
|
||||
</Quick>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Absolute Date 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>
|
||||
<DatePicker
|
||||
placeholder="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>
|
||||
<DatePicker
|
||||
placeholder="End date"
|
||||
selected={before ? new Date(before) : undefined}
|
||||
onSelect={(date) => setBefore(date?.getTime())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{(since || before) && (
|
||||
<div className="px-1 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clear}
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="text-[11px] font-semibold mb-2.5 text-muted-foreground uppercase tracking-wider">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Quick({
|
||||
children,
|
||||
onClick,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-7 px-2.5 text-xs font-normal hover:bg-primary/5 hover:text-primary shrink-0",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user