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:
@@ -16,6 +16,8 @@
|
|||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::modules::account::migration::AccountModel;
|
use crate::modules::account::migration::AccountModel;
|
||||||
use crate::modules::cache::imap::mailbox::MailBox;
|
use crate::modules::cache::imap::mailbox::MailBox;
|
||||||
use crate::modules::error::code::ErrorCode;
|
use crate::modules::error::code::ErrorCode;
|
||||||
@@ -155,9 +157,9 @@ impl Envelope {
|
|||||||
let id = create_hash(account_id, &message_id);
|
let id = create_hash(account_id, &message_id);
|
||||||
let full_text = extract_string_field(doc, fields.f_text)?;
|
let full_text = extract_string_field(doc, fields.f_text)?;
|
||||||
|
|
||||||
// Take up to the first 120 characters as a preview;
|
// Take up to the first 500 characters as a preview;
|
||||||
let preview = if full_text.chars().count() > 120 {
|
let preview = if full_text.chars().count() > 500 {
|
||||||
full_text.chars().take(120).collect::<String>() + "..."
|
full_text.chars().take(500).collect::<String>() + "..."
|
||||||
} else {
|
} else {
|
||||||
full_text
|
full_text
|
||||||
};
|
};
|
||||||
@@ -204,3 +206,28 @@ impl Envelope {
|
|||||||
Ok(envelope)
|
Ok(envelope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
|
||||||
|
let fields = SchemaTools::envelope_fields();
|
||||||
|
let mut all_contacts = HashSet::new();
|
||||||
|
|
||||||
|
if let Ok(from_val) = extract_string_field(doc, fields.f_from) {
|
||||||
|
if !from_val.is_empty() {
|
||||||
|
all_contacts.insert(from_val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let multi_fields = [fields.f_to, fields.f_cc, fields.f_bcc];
|
||||||
|
|
||||||
|
for field in multi_fields {
|
||||||
|
if let Ok(vals) = extract_vec_string_field(doc, field) {
|
||||||
|
for v in vals {
|
||||||
|
if !v.is_empty() {
|
||||||
|
all_contacts.insert(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(all_contacts)
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ use std::{
|
|||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::modules::message::{search::SortBy, tags::TagCount};
|
use crate::modules::{
|
||||||
|
indexer::envelope::extract_contacts,
|
||||||
|
message::{search::SortBy, tags::TagCount},
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
modules::{
|
modules::{
|
||||||
account::migration::AccountModel,
|
account::migration::AccountModel,
|
||||||
@@ -57,7 +60,10 @@ use tantivy::{
|
|||||||
AggregationCollector, Key,
|
AggregationCollector, Key,
|
||||||
},
|
},
|
||||||
collector::{Count, FacetCollector, TopDocs},
|
collector::{Count, FacetCollector, TopDocs},
|
||||||
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, RegexQuery, TermQuery},
|
query::{
|
||||||
|
AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, RegexQuery,
|
||||||
|
TermQuery,
|
||||||
|
},
|
||||||
schema::{Facet, IndexRecordOption, Value},
|
schema::{Facet, IndexRecordOption, Value},
|
||||||
store::{Compressor, ZstdCompressor},
|
store::{Compressor, ZstdCompressor},
|
||||||
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
|
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
|
||||||
@@ -307,10 +313,7 @@ impl EnvelopeIndexManager {
|
|||||||
] {
|
] {
|
||||||
if let Some(ref v) = opt_value {
|
if let Some(ref v) = opt_value {
|
||||||
if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) {
|
if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) {
|
||||||
subqueries.push((
|
subqueries.push((Occur::Must, Box::new(query)));
|
||||||
Occur::Must,
|
|
||||||
Box::new(query),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,10 +332,7 @@ impl EnvelopeIndexManager {
|
|||||||
|
|
||||||
if let Some(ref name) = filter.attachment_name {
|
if let Some(ref name) = filter.attachment_name {
|
||||||
if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) {
|
if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) {
|
||||||
subqueries.push((
|
subqueries.push((Occur::Must, Box::new(query)));
|
||||||
Occur::Must,
|
|
||||||
Box::new(query),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +530,48 @@ impl EnvelopeIndexManager {
|
|||||||
Ok(all_facets)
|
Ok(all_facets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_contacts(
|
||||||
|
&self,
|
||||||
|
accounts: Option<HashSet<u64>>,
|
||||||
|
) -> BichonResult<HashSet<String>> {
|
||||||
|
let searcher = self.create_searcher()?;
|
||||||
|
|
||||||
|
let query: Box<dyn Query> = match accounts {
|
||||||
|
Some(ref ids) if !ids.is_empty() => {
|
||||||
|
let mut subqueries = Vec::new();
|
||||||
|
for &id in ids {
|
||||||
|
let term =
|
||||||
|
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
|
||||||
|
subqueries.push((
|
||||||
|
Occur::Should,
|
||||||
|
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Box::new(BooleanQuery::new(subqueries))
|
||||||
|
}
|
||||||
|
Some(_) => Box::new(EmptyQuery),
|
||||||
|
None => Box::new(AllQuery),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut contacts_set: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
let top_docs = searcher
|
||||||
|
.search(&query, &TopDocs::with_limit(1_000_000))
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
for (_score, doc_address) in top_docs {
|
||||||
|
let doc: TantivyDocument = searcher
|
||||||
|
.doc_async(doc_address)
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
let contacts = extract_contacts(&doc).await?;
|
||||||
|
for value in contacts {
|
||||||
|
contacts_set.insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(contacts_set)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_envelopes_multi_account(
|
pub async fn delete_envelopes_multi_account(
|
||||||
&self,
|
&self,
|
||||||
deletes: &HashMap<u64, Vec<u64>>, // HashMap<account_id, envelope_ids>
|
deletes: &HashMap<u64, Vec<u64>>, // HashMap<account_id, envelope_ids>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use poem_openapi::Object;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||||
|
pub struct Contact {
|
||||||
|
pub email: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
pub mod append;
|
pub mod append;
|
||||||
|
pub mod contacts;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
pub mod delete;
|
pub mod delete;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
|
|||||||
@@ -323,4 +323,25 @@ impl MessageApi {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[oai(
|
||||||
|
path = "/all-contacts",
|
||||||
|
method = "get",
|
||||||
|
operation_id = "get_all_contacts"
|
||||||
|
)]
|
||||||
|
async fn get_all_contacts(&self, context: ClientContext) -> ApiResult<Json<HashSet<String>>> {
|
||||||
|
let authorized_ids: Option<HashSet<u64>> = if context
|
||||||
|
.has_permission(None, Permission::DATA_READ_ALL)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(context.user.account_access_map.keys().cloned().collect())
|
||||||
|
};
|
||||||
|
Ok(Json(
|
||||||
|
ENVELOPE_INDEX_MANAGER
|
||||||
|
.get_all_contacts(authorized_ids)
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export interface TagCount {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const get_top_tags = async () => {
|
export const get_tags = async () => {
|
||||||
const response = await axiosInstance.get<TagCount[]>("/api/v1/all-tags");
|
const response = await axiosInstance.get<TagCount[]>("/api/v1/all-tags");
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -41,3 +41,9 @@ export const update_tags = async (data: Record<string, any>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const get_contacts = async () => {
|
||||||
|
const response = await axiosInstance.get<string[]>("/api/v1/all-contacts");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function DatePicker({
|
|||||||
{selected ? (
|
{selected ? (
|
||||||
format(selected, 'PPP', { locale: dateLocale })
|
format(selected, 'PPP', { locale: dateLocale })
|
||||||
) : (
|
) : (
|
||||||
<span>{placeholder}</span>
|
<span className='text-xs'>{placeholder}</span>
|
||||||
)}
|
)}
|
||||||
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
|
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function EnvelopeListPagination({
|
|||||||
<SelectValue placeholder={pageSize} />
|
<SelectValue placeholder={pageSize} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent side='top'>
|
<SelectContent side='top'>
|
||||||
{[10, 20, 30, 40, 50, 100].map((size) => (
|
{[10, 20, 30, 40, 50, 100, 200].map((size) => (
|
||||||
<SelectItem key={size} value={`${size}`}>
|
<SelectItem key={size} value={`${size}`}>
|
||||||
{size}
|
{size}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -103,15 +103,18 @@ export function EnvelopeListPagination({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-center text-sm font-medium'>
|
<div className='hidden items-center justify-center text-sm font-medium sm:flex'>
|
||||||
{t("table.page")}
|
{t("table.page")}
|
||||||
<Input type="number" value={pageInput} onBlur={() => {
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={pageInput}
|
||||||
|
onBlur={() => {
|
||||||
if (Number.isNaN(pageInput)) return
|
if (Number.isNaN(pageInput)) return
|
||||||
if (pageInput > 0) setPageIndex(pageInput - 1)
|
if (pageInput > 0) setPageIndex(pageInput - 1)
|
||||||
else setPageIndex(0)
|
else setPageIndex(0)
|
||||||
}}
|
}}
|
||||||
onChange={(e) => setPageInput(Number(e.target.value))}
|
onChange={(e) => setPageInput(Number(e.target.value))}
|
||||||
className='mx-2 w-20'
|
className='mx-2 h-8 w-20'
|
||||||
/>
|
/>
|
||||||
{t("table.of")} {pageCount}
|
{t("table.of")} {pageCount}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ const Table = React.forwardRef<
|
|||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className='relative w-full overflow-auto'>
|
// <div className='relative w-full overflow-auto'>
|
||||||
<table
|
<table
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('w-full caption-bottom text-sm', className)}
|
className={cn('w-full caption-bottom text-sm', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
// </div>
|
||||||
))
|
))
|
||||||
Table.displayName = 'Table'
|
Table.displayName = 'Table'
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
<thead ref={ref} className={cn('[&_th]:sticky [&_th]:top-0 [&_th]:bg-background [&_th]:z-20', className)} {...props} />
|
||||||
))
|
))
|
||||||
TableHeader.displayName = 'TableHeader'
|
TableHeader.displayName = 'TableHeader'
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ interface VirtualizedSelectProps {
|
|||||||
defaultValue?: string | string[];
|
defaultValue?: string | string[];
|
||||||
noItemsComponent?: React.ReactNode;
|
noItemsComponent?: React.ReactNode;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
|
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VirtualizedSelect({
|
export function VirtualizedSelect({
|
||||||
@@ -232,6 +233,7 @@ export function VirtualizedSelect({
|
|||||||
className,
|
className,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
value,
|
value,
|
||||||
|
size = 'default',
|
||||||
isLoading,
|
isLoading,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
placeholder = 'Search items...',
|
placeholder = 'Search items...',
|
||||||
@@ -281,6 +283,7 @@ export function VirtualizedSelect({
|
|||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
size={size}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
|||||||
@@ -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 React from 'react'
|
||||||
import { EmailEnvelope } from '@/api'
|
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 {
|
interface SearchContextType {
|
||||||
open: SearchDialogType | null
|
open: SearchDialogType | null
|
||||||
@@ -35,8 +35,9 @@ interface SearchContextType {
|
|||||||
selectedTags: string[]
|
selectedTags: string[]
|
||||||
sorting: SortingState
|
sorting: SortingState
|
||||||
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
|
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
|
||||||
columnVisibility: VisibilityState
|
filter: Record<string, any>
|
||||||
setColumnVisibility: React.Dispatch<React.SetStateAction<VisibilityState>>
|
setFilter: React.Dispatch<React.SetStateAction<Record<string, any>>>
|
||||||
|
handleTagToggle: (tag: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchContext = React.createContext<SearchContextType | null>(null)
|
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 { EnvelopeListPagination } from '@/components/pagination';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { EmailEnvelope } from '@/api';
|
import { EmailEnvelope } from '@/api';
|
||||||
import { Filter, SearchIcon, SquarePen } from 'lucide-react';
|
import { Filter, SearchIcon } from 'lucide-react';
|
||||||
import { MailDisplayDrawer } from './mail-display-dialog';
|
import { MailDisplayDrawer } from './mail-display-dialog';
|
||||||
import { EnvelopeDeleteDialog } from './delete-dialog';
|
import { EnvelopeDeleteDialog } from './delete-dialog';
|
||||||
import SearchProvider, { SearchDialogType } from './context';
|
import SearchProvider, { SearchDialogType } from './context';
|
||||||
import useDialogState from '@/hooks/use-dialog-state';
|
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 { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { EnvelopeTags } from './tag-facet';
|
import { EnvelopeTags } from './tag-facet';
|
||||||
@@ -38,9 +37,8 @@ import { EditTagsDialog } from './add-tag-dialog';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Logo from '@/assets/logo.svg'
|
import Logo from '@/assets/logo.svg'
|
||||||
import { RestoreMessageDialog } from './restore-message-dialog';
|
import { RestoreMessageDialog } from './restore-message-dialog';
|
||||||
import { ColumnsDialog } from './columns-dialog';
|
|
||||||
import { MailListTable } from './mail-list-table';
|
import { MailListTable } from './mail-list-table';
|
||||||
import { SortingState, VisibilityState } from '@tanstack/react-table';
|
import { SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -50,10 +48,6 @@ export default function Search() {
|
|||||||
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
|
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
|
||||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
|
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 {
|
const {
|
||||||
emails,
|
emails,
|
||||||
@@ -69,7 +63,8 @@ export default function Search() {
|
|||||||
setSortOrder,
|
setSortOrder,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
reset,
|
reset,
|
||||||
filter
|
filter,
|
||||||
|
setFilter
|
||||||
} = useSearchMessages();
|
} = useSearchMessages();
|
||||||
|
|
||||||
const handleSetPageSize = (pageSize: number) => {
|
const handleSetPageSize = (pageSize: number) => {
|
||||||
@@ -108,64 +103,22 @@ export default function Search() {
|
|||||||
setSelected,
|
setSelected,
|
||||||
sorting,
|
sorting,
|
||||||
setSorting,
|
setSorting,
|
||||||
columnVisibility,
|
filter,
|
||||||
setColumnVisibility
|
setFilter,
|
||||||
|
handleTagToggle
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full px-4">
|
<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">
|
<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">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
<EnvelopeTags
|
<EnvelopeTags
|
||||||
selectedTags={selectedTags}
|
selectedTags={selectedTags}
|
||||||
onTagToggle={handleTagToggle}
|
onTagToggle={handleTagToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside> */}
|
||||||
<div className="flex-1 min-w-0 space-y-4">
|
<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 && (
|
{isLoading && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12">
|
<CardContent className="py-12">
|
||||||
@@ -177,7 +130,7 @@ export default function Search() {
|
|||||||
</Card>
|
</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">
|
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||||
<img
|
<img
|
||||||
src={Logo}
|
src={Logo}
|
||||||
@@ -191,8 +144,7 @@ export default function Search() {
|
|||||||
: t('search.adjustSearch')}
|
: t('search.adjustSearch')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>} */}
|
||||||
{total > 0 && <ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1' orientation='both'>
|
|
||||||
<MailListTable
|
<MailListTable
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
items={emails}
|
items={emails}
|
||||||
@@ -203,7 +155,6 @@ export default function Search() {
|
|||||||
setSortBy={setSortBy}
|
setSortBy={setSortBy}
|
||||||
setSortOrder={setSortOrder}
|
setSortOrder={setSortOrder}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>}
|
|
||||||
{total > 0 && <EnvelopeListPagination
|
{total > 0 && <EnvelopeListPagination
|
||||||
totalItems={total}
|
totalItems={total}
|
||||||
hasNextPage={() => page < totalPages}
|
hasNextPage={() => page < totalPages}
|
||||||
@@ -246,13 +197,6 @@ export default function Search() {
|
|||||||
open={open === 'restore'}
|
open={open === 'restore'}
|
||||||
onOpenChange={() => setOpen('restore')}
|
onOpenChange={() => setOpen('restore')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ColumnsDialog
|
|
||||||
key='columns-dialog'
|
|
||||||
open={open === 'columns'}
|
|
||||||
onOpenChange={() => setOpen('columns')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</SearchProvider>
|
</SearchProvider>
|
||||||
</Main>
|
</Main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
import { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||||
import { format, formatDistanceToNow } from "date-fns"
|
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 { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { EmailEnvelope } from "@/api"
|
import { EmailEnvelope } from "@/api"
|
||||||
@@ -32,7 +32,9 @@ import LongText from "@/components/long-text"
|
|||||||
import { DataTableColumnHeader } from "./table/data-table-column-header"
|
import { DataTableColumnHeader } from "./table/data-table-column-header"
|
||||||
import { SearchTable } from "./table/table"
|
import { SearchTable } from "./table/table"
|
||||||
import { DataTableRowActions } from "./table/data-table-row-actions"
|
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 {
|
interface MailListProps {
|
||||||
items: EmailEnvelope[]
|
items: EmailEnvelope[]
|
||||||
@@ -85,43 +87,128 @@ export function MailListTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "account_email",
|
accessorKey: "account_email",
|
||||||
header: t('search.account'),
|
header: t('search.account'),
|
||||||
cell: ({ row }) => <LongText className='text-xs max-w-[150px]'>{row.original.account_email}</LongText>,
|
cell: ({ row }) => <LongText className='text-xs'>{row.original.account_email}</LongText>,
|
||||||
meta: { className: 'text-left text-sm' },
|
meta: { className: 'text-left text-xs' },
|
||||||
minSize: 166
|
minSize: 150,
|
||||||
|
maxSize: 156,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "mailbox_name",
|
accessorKey: "mailbox_name",
|
||||||
header: t('search.mailbox'),
|
header: t('search.mailbox'),
|
||||||
cell: ({ row }) => <LongText className='text-xs max-w-[100px]'>{row.original.mailbox_name}</LongText>,
|
cell: ({ row }) => {
|
||||||
meta: { className: 'text-left text-sm' },
|
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>
|
||||||
|
<div className="flex flex-col leading-tight max-w-[130px] cursor-default">
|
||||||
|
<span className="text-xs truncate">
|
||||||
|
{mailbox}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{visible.length > 0 && (
|
||||||
|
<span className="text-[10px] text-primary/80 truncate">
|
||||||
|
{visible.join(' · ')}
|
||||||
|
{rest > 0 && ` · +${rest}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
className="max-w-xs"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium mb-1">
|
||||||
|
{mailbox}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[11px] text-muted-foreground break-words">
|
||||||
|
{fullTags}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { className: 'text-left text-xs' },
|
||||||
minSize: 116,
|
minSize: 116,
|
||||||
maxSize: 116,
|
maxSize: 116,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "from",
|
accessorKey: "from",
|
||||||
header: t('search.from'),
|
header: t('search.from'),
|
||||||
cell: ({ row }) => <LongText className='text-xs max-w-[134px]'>{row.original.from}</LongText>,
|
cell: ({ row }) => <LongText className='text-xs'>{row.original.from}</LongText>,
|
||||||
meta: { className: 'text-left text-sm' },
|
meta: { className: 'text-left text-xs' },
|
||||||
minSize: 150,
|
minSize: 150,
|
||||||
|
maxSize: 156,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "to",
|
accessorKey: "to",
|
||||||
header: t('search.to'),
|
header: t('search.to'),
|
||||||
cell: ({ row }) => <LongText className='text-xs max-w-[180px]'>{row.original.to.join(", ")}</LongText>,
|
cell: ({ row }) => <LongText className='text-xs'>{row.original.to.join(", ")}</LongText>,
|
||||||
meta: { className: 'text-left text-sm' },
|
meta: { className: 'text-left text-xs' },
|
||||||
|
minSize: 150,
|
||||||
|
maxSize: 156,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "subject",
|
accessorKey: "subject",
|
||||||
header: t('search.subject'),
|
header: t('search.subject'),
|
||||||
cell: ({ row }) => <LongText className='text-xs max-w-[500px]'>{row.original.subject}</LongText>,
|
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
|
||||||
meta: { className: 'text-left text-sm' },
|
meta: { className: 'text-left text-xs' },
|
||||||
size: 1000
|
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",
|
id: "attachment_count",
|
||||||
header: () => <Paperclip size={16} />,
|
header: () => <Paperclip size={16} />,
|
||||||
cell: ({ row }) => <span className='text-xs'>{(row.original.attachments ?? []).length}</span>,
|
cell: ({ row }) => <span className='text-xs'>{(row.original.attachments ?? []).length}</span>,
|
||||||
meta: { className: 'text-left text-sm' },
|
meta: { className: 'text-left text-xs' },
|
||||||
minSize: 40,
|
minSize: 40,
|
||||||
maxSize: 40
|
maxSize: 40
|
||||||
},
|
},
|
||||||
@@ -131,7 +218,7 @@ export function MailListTable({
|
|||||||
<DataTableColumnHeader column={column} title={t('search.size')} />
|
<DataTableColumnHeader column={column} title={t('search.size')} />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
|
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
|
||||||
meta: { className: 'text-left text-sm' },
|
meta: { className: 'text-left text-xs' },
|
||||||
minSize: 100,
|
minSize: 100,
|
||||||
maxSize: 100,
|
maxSize: 100,
|
||||||
},
|
},
|
||||||
@@ -154,15 +241,17 @@ export function MailListTable({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: { className: 'text-left text-sm' },
|
meta: { className: 'text-left text-xs' },
|
||||||
minSize: 100,
|
minSize: 100,
|
||||||
|
maxSize: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: t('users.columns.actions'),
|
header: t('users.columns.actions'),
|
||||||
cell: DataTableRowActions,
|
cell: DataTableRowActions,
|
||||||
minSize: 70,
|
meta: { className: 'text-left text-xs' },
|
||||||
maxSize: 70,
|
minSize: 50,
|
||||||
|
maxSize: 60,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -232,7 +321,12 @@ export function MailListTable({
|
|||||||
}}
|
}}
|
||||||
setSortBy={setSortBy}
|
setSortBy={setSortBy}
|
||||||
setSortOrder={setSortOrder}
|
setSortOrder={setSortOrder}
|
||||||
/>
|
>
|
||||||
|
{(table) => {
|
||||||
|
return <DataTableToolbar table={table} />
|
||||||
|
}}
|
||||||
|
|
||||||
|
</SearchTable>
|
||||||
{totalSelected > 0 && <MailBulkActions />}
|
{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>
|
<div>
|
||||||
{showAdvanced && <Accordion type="multiple" className="space-y-3">
|
{showAdvanced && <Accordion type="multiple" className="space-y-3">
|
||||||
{/* Sender & Recipients */}
|
|
||||||
<AccordionItem value="people">
|
<AccordionItem value="people">
|
||||||
<AccordionTrigger className="text-sm">
|
<AccordionTrigger className="text-sm">
|
||||||
{t('search.sender')} / {t('search.recipient')}
|
{t('search.sender')} / {t('search.recipient')}
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ import {
|
|||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
|
import { type Table } from '@tanstack/react-table'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table as ShadcnTable,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
@@ -43,6 +44,9 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { EmailEnvelope } from '@/api'
|
import { EmailEnvelope } from '@/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSearchContext } from '../context'
|
import { useSearchContext } from '../context'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
declare module '@tanstack/react-table' {
|
declare module '@tanstack/react-table' {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
@@ -57,10 +61,11 @@ interface DataTableProps {
|
|||||||
onRowClick: (e: ReactMouseEvent<HTMLTableRowElement, MouseEvent>, row: Row<EmailEnvelope>) => void
|
onRowClick: (e: ReactMouseEvent<HTMLTableRowElement, MouseEvent>, row: Row<EmailEnvelope>) => void
|
||||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||||
setSortOrder: (value: "desc" | "asc") => void
|
setSortOrder: (value: "desc" | "asc") => void
|
||||||
|
children?: (table: Table<EmailEnvelope>) => React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder }: DataTableProps) {
|
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder, children }: DataTableProps) {
|
||||||
const { sorting, setSorting, columnVisibility, setColumnVisibility } = useSearchContext()
|
const { sorting, setSorting } = useSearchContext()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [rowSelection, setRowSelection] = useState({})
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
@@ -76,7 +81,6 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
|||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnVisibility,
|
|
||||||
rowSelection,
|
rowSelection,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
},
|
},
|
||||||
@@ -84,7 +88,6 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
|||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
@@ -93,9 +96,10 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className="flex flex-1 flex-col gap-0.5">
|
||||||
<div className='rounded-md border'>
|
{children && (<>{children(table)}</>)}
|
||||||
<Table>
|
<ScrollArea className='h-[calc(100vh-13rem)] rounded-md border' orientation='both'>
|
||||||
|
<ShadcnTable>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id} className='group/row'>
|
<TableRow key={headerGroup.id} className='group/row'>
|
||||||
@@ -155,9 +159,10 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</ShadcnTable>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
import { get_top_tags } from '@/api/search/api';
|
import { get_tags } from '@/api/search/api';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export function useAvailableTags(): UseAvailableTagsResult {
|
|||||||
refetch,
|
refetch,
|
||||||
} = useQuery<TagCount[]>({
|
} = useQuery<TagCount[]>({
|
||||||
queryKey: ['all-tags'],
|
queryKey: ['all-tags'],
|
||||||
queryFn: get_top_tags,
|
queryFn: get_tags,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
retry: false,
|
retry: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { get_contacts } from '@/api/search/api';
|
||||||
|
|
||||||
|
export const useContacts = (searchTerm: string = "") => {
|
||||||
|
const { data: allContacts = [], isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['contacts', 'all'],
|
||||||
|
queryFn: get_contacts,
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
gcTime: 1000 * 60 * 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!searchTerm) return allContacts;
|
||||||
|
const lower = searchTerm.toLowerCase();
|
||||||
|
return allContacts.filter(email =>
|
||||||
|
email.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
}, [allContacts, searchTerm]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contacts: filtered,
|
||||||
|
isLoading,
|
||||||
|
isError
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -92,6 +92,7 @@ export function useSearchMessages() {
|
|||||||
setPage,
|
setPage,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
reset,
|
reset,
|
||||||
filter
|
filter,
|
||||||
|
setFilter
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -47,8 +47,7 @@
|
|||||||
"op_failed": "Operation failed",
|
"op_failed": "Operation failed",
|
||||||
"na": "N/A",
|
"na": "N/A",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"deleting": "Deleting...",
|
"deleting": "Deleting..."
|
||||||
"columns": "Columns"
|
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
|
|||||||
Reference in New Issue
Block a user