diff --git a/src/modules/indexer/envelope.rs b/src/modules/indexer/envelope.rs index e89c65d..24dd96c 100644 --- a/src/modules/indexer/envelope.rs +++ b/src/modules/indexer/envelope.rs @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use std::collections::HashSet; + use crate::modules::account::migration::AccountModel; use crate::modules::cache::imap::mailbox::MailBox; use crate::modules::error::code::ErrorCode; @@ -155,9 +157,9 @@ impl Envelope { let id = create_hash(account_id, &message_id); let full_text = extract_string_field(doc, fields.f_text)?; - // Take up to the first 120 characters as a preview; - let preview = if full_text.chars().count() > 120 { - full_text.chars().take(120).collect::() + "..." + // Take up to the first 500 characters as a preview; + let preview = if full_text.chars().count() > 500 { + full_text.chars().take(500).collect::() + "..." } else { full_text }; @@ -204,3 +206,28 @@ impl Envelope { Ok(envelope) } } + +pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult> { + 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) +} diff --git a/src/modules/indexer/manager.rs b/src/modules/indexer/manager.rs index 42445dd..d019e67 100644 --- a/src/modules/indexer/manager.rs +++ b/src/modules/indexer/manager.rs @@ -24,7 +24,10 @@ use std::{ time::Duration, }; -use crate::modules::message::{search::SortBy, tags::TagCount}; +use crate::modules::{ + indexer::envelope::extract_contacts, + message::{search::SortBy, tags::TagCount}, +}; use crate::{ modules::{ account::migration::AccountModel, @@ -57,7 +60,10 @@ use tantivy::{ AggregationCollector, Key, }, 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}, store::{Compressor, ZstdCompressor}, DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order, @@ -307,10 +313,7 @@ impl EnvelopeIndexManager { ] { if let Some(ref v) = opt_value { if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) { - subqueries.push(( - Occur::Must, - Box::new(query), - )); + subqueries.push((Occur::Must, Box::new(query))); } } } @@ -329,10 +332,7 @@ impl EnvelopeIndexManager { if let Some(ref name) = filter.attachment_name { if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) { - subqueries.push(( - Occur::Must, - Box::new(query), - )); + subqueries.push((Occur::Must, Box::new(query))); } } @@ -530,6 +530,48 @@ impl EnvelopeIndexManager { Ok(all_facets) } + pub async fn get_all_contacts( + &self, + accounts: Option>, + ) -> BichonResult> { + let searcher = self.create_searcher()?; + + let query: Box = 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, + )); + } + Box::new(BooleanQuery::new(subqueries)) + } + Some(_) => Box::new(EmptyQuery), + None => Box::new(AllQuery), + }; + + let mut contacts_set: HashSet = 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( &self, deletes: &HashMap>, // HashMap diff --git a/src/modules/message/contacts.rs b/src/modules/message/contacts.rs new file mode 100644 index 0000000..ccfe081 --- /dev/null +++ b/src/modules/message/contacts.rs @@ -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, +} + + diff --git a/src/modules/message/mod.rs b/src/modules/message/mod.rs index 41bf666..7e3bcca 100644 --- a/src/modules/message/mod.rs +++ b/src/modules/message/mod.rs @@ -17,6 +17,7 @@ // along with this program. If not, see . pub mod append; +pub mod contacts; pub mod content; pub mod delete; pub mod list; diff --git a/src/modules/rest/api/message.rs b/src/modules/rest/api/message.rs index da33889..bdc4c55 100644 --- a/src/modules/rest/api/message.rs +++ b/src/modules/rest/api/message.rs @@ -323,4 +323,25 @@ impl MessageApi { .await?; Ok(()) } + + #[oai( + path = "/all-contacts", + method = "get", + operation_id = "get_all_contacts" + )] + async fn get_all_contacts(&self, context: ClientContext) -> ApiResult>> { + let authorized_ids: Option> = 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?, + )) + } } diff --git a/web/src/api/search/api.ts b/web/src/api/search/api.ts index 99e803a..c970b57 100644 --- a/web/src/api/search/api.ts +++ b/web/src/api/search/api.ts @@ -30,7 +30,7 @@ export interface TagCount { count: number; } -export const get_top_tags = async () => { +export const get_tags = async () => { const response = await axiosInstance.get("/api/v1/all-tags"); return response.data; } @@ -41,3 +41,9 @@ export const update_tags = async (data: Record) => { }; +export const get_contacts = async () => { + const response = await axiosInstance.get("/api/v1/all-contacts"); + return response.data; +} + + diff --git a/web/src/components/date-picker.tsx b/web/src/components/date-picker.tsx index 625dfdb..fce1b09 100644 --- a/web/src/components/date-picker.tsx +++ b/web/src/components/date-picker.tsx @@ -37,7 +37,7 @@ export function DatePicker({ {selected ? ( format(selected, 'PPP', { locale: dateLocale }) ) : ( - {placeholder} + {placeholder} )} diff --git a/web/src/components/pagination.tsx b/web/src/components/pagination.tsx index 644830e..63975d6 100644 --- a/web/src/components/pagination.tsx +++ b/web/src/components/pagination.tsx @@ -95,7 +95,7 @@ export function EnvelopeListPagination({ - {[10, 20, 30, 40, 50, 100].map((size) => ( + {[10, 20, 30, 40, 50, 100, 200].map((size) => ( {size} @@ -103,15 +103,18 @@ export function EnvelopeListPagination({ -
+
{t("table.page")} - { - if (Number.isNaN(pageInput)) return - if (pageInput > 0) setPageIndex(pageInput - 1) - else setPageIndex(0) - }} - onChange={(e) => setPageInput(Number(e.target.value))} - className='mx-2 w-20' + { + if (Number.isNaN(pageInput)) return + if (pageInput > 0) setPageIndex(pageInput - 1) + else setPageIndex(0) + }} + onChange={(e) => setPageInput(Number(e.target.value))} + className='mx-2 h-8 w-20' /> {t("table.of")} {pageCount}
diff --git a/web/src/components/ui/scroll-area.tsx b/web/src/components/ui/scroll-area.tsx index ba9b499..5f40079 100644 --- a/web/src/components/ui/scroll-area.tsx +++ b/web/src/components/ui/scroll-area.tsx @@ -45,9 +45,9 @@ const ScrollBar = React.forwardRef< className={cn( 'flex touch-none select-none transition-colors', orientation === 'vertical' && - 'h-full w-2.5 border-l border-l-transparent p-[1px]', + 'h-full w-2.5 border-l border-l-transparent p-[1px]', orientation === 'horizontal' && - 'h-2.5 flex-col border-t border-t-transparent p-[1px]', + 'h-2.5 flex-col border-t border-t-transparent p-[1px]', className )} {...props} diff --git a/web/src/components/ui/table.tsx b/web/src/components/ui/table.tsx index 1826e9c..a407429 100644 --- a/web/src/components/ui/table.tsx +++ b/web/src/components/ui/table.tsx @@ -5,13 +5,13 @@ const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+ //
- + // )) Table.displayName = 'Table' @@ -19,7 +19,7 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )) TableHeader.displayName = 'TableHeader' diff --git a/web/src/components/virtualized-select.tsx b/web/src/components/virtualized-select.tsx index 701c846..ed445e0 100644 --- a/web/src/components/virtualized-select.tsx +++ b/web/src/components/virtualized-select.tsx @@ -224,6 +224,7 @@ interface VirtualizedSelectProps { defaultValue?: string | string[]; noItemsComponent?: React.ReactNode; multiple?: boolean; + size?: 'default' | 'sm' | 'lg' | 'icon'; } export function VirtualizedSelect({ @@ -232,6 +233,7 @@ export function VirtualizedSelect({ className, defaultValue, value, + size = 'default', isLoading, disabled = false, placeholder = 'Search items...', @@ -281,6 +283,7 @@ export function VirtualizedSelect({ + + + +
+ setSearch(e.target.value)} + placeholder={t('search.searchAccount')} + className="h-8 text-sm" + /> +
+ {!search && selectedIds.length > 0 && ( +
+ +
+
+ )} + + {filtered.map(account => { + const checked = selectedIds.includes(account.id) + const id = `account-${account.id}` + + return ( +
toggleAccount(account.id)} + className={cn( + 'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer', + 'hover:bg-accent transition-colors' + )} + > + + toggleAccount(account.id) + } + onClick={e => e.stopPropagation()} + /> + + +
+ ) + })} +
+ + + ) +} diff --git a/web/src/features/search/attachment-filter.tsx b/web/src/features/search/attachment-filter.tsx new file mode 100644 index 0000000..51bf5a8 --- /dev/null +++ b/web/src/features/search/attachment-filter.tsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/web/src/features/search/columns-dialog.tsx b/web/src/features/search/columns-dialog.tsx deleted file mode 100755 index 3545576..0000000 --- a/web/src/features/search/columns-dialog.tsx +++ /dev/null @@ -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 . - -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 - : 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 ( - - - - - - {t('common.columns')} - - - -
-
- {columns.map(col => ( -
- toggleSelected(col.value)} - /> - -
- ))} -
-
- -
-
- - -
-
-
-
- ) -} diff --git a/web/src/features/search/contact-popover.tsx b/web/src/features/search/contact-popover.tsx new file mode 100644 index 0000000..0c6de8f --- /dev/null +++ b/web/src/features/search/contact-popover.tsx @@ -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 ( + + + + + + +
+ {fields.map((field) => ( + updateFilter(field, email)} + onReset={() => updateFilter(field, undefined)} + /> + ))} +
+ + {activeCount > 0 && ( +
+ +
+ )} +
+
+ ) +} + +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 ( + + + + )} +
+ + {value && ( +
+ )} + + + + + + + + {isLoading && ( +
Loading...
+ )} + No contact found. + + {contacts.slice(0, 100).map((email) => ( + handleToggle(email)} + className="flex items-center justify-between py-2.5 px-3 cursor-pointer whitespace-nowrap gap-4 text-xs" + > +
+ + {email.split('@')[0]} + + + {email} + +
+ {value === email && ( + + )} +
+ ))} + {contacts.length > 100 && ( +
+ Showing top 100 results • {contacts.length} total +
+ )} +
+
+
+
+ + ) +} \ No newline at end of file diff --git a/web/src/features/search/context/index.tsx b/web/src/features/search/context/index.tsx index 3698646..30bbfd8 100644 --- a/web/src/features/search/context/index.tsx +++ b/web/src/features/search/context/index.tsx @@ -19,9 +19,9 @@ import React from 'react' import { EmailEnvelope } from '@/api' -import { SortingState, VisibilityState } from '@tanstack/react-table' +import { SortingState } from '@tanstack/react-table' -export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore' | 'columns' +export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore' interface SearchContextType { open: SearchDialogType | null @@ -35,8 +35,9 @@ interface SearchContextType { selectedTags: string[] sorting: SortingState setSorting: React.Dispatch> - columnVisibility: VisibilityState - setColumnVisibility: React.Dispatch> + filter: Record + setFilter: React.Dispatch>> + handleTagToggle: (tag: string) => void } const SearchContext = React.createContext(null) diff --git a/web/src/features/search/filter-reset.tsx b/web/src/features/search/filter-reset.tsx new file mode 100644 index 0000000..dbf1744 --- /dev/null +++ b/web/src/features/search/filter-reset.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/web/src/features/search/index.tsx b/web/src/features/search/index.tsx index a5cf1e4..3818e2a 100644 --- a/web/src/features/search/index.tsx +++ b/web/src/features/search/index.tsx @@ -25,12 +25,11 @@ import { SearchFormDialog } from './search-form'; import { EnvelopeListPagination } from '@/components/pagination'; import React from 'react'; import { EmailEnvelope } from '@/api'; -import { Filter, SearchIcon, SquarePen } from 'lucide-react'; +import { Filter, SearchIcon } from 'lucide-react'; import { MailDisplayDrawer } from './mail-display-dialog'; import { EnvelopeDeleteDialog } from './delete-dialog'; import SearchProvider, { SearchDialogType } from './context'; import useDialogState from '@/hooks/use-dialog-state'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; import { Button } from '@/components/ui/button'; import { EnvelopeTags } from './tag-facet'; @@ -38,9 +37,8 @@ import { EditTagsDialog } from './add-tag-dialog'; import { useTranslation } from 'react-i18next'; import Logo from '@/assets/logo.svg' import { RestoreMessageDialog } from './restore-message-dialog'; -import { ColumnsDialog } from './columns-dialog'; import { MailListTable } from './mail-list-table'; -import { SortingState, VisibilityState } from '@tanstack/react-table'; +import { SortingState } from '@tanstack/react-table'; export default function Search() { const { t } = useTranslation() @@ -50,10 +48,6 @@ export default function Search() { const [selected, setSelected] = React.useState>>(new Map()); const [selectedTags, setSelectedTags] = React.useState([]); const [sorting, setSorting] = React.useState([{ id: "date", desc: true }]); - const [columnVisibility, setColumnVisibility] = React.useState(localStorage.getItem("searchTableColumns") - ? JSON.parse(localStorage.getItem("searchTableColumns") as string) as Record - : {} - ) const { emails, @@ -69,7 +63,8 @@ export default function Search() { setSortOrder, onSubmit, reset, - filter + filter, + setFilter } = useSearchMessages(); const handleSetPageSize = (pageSize: number) => { @@ -96,7 +91,7 @@ export default function Search() {
-
- - - - - - - {t('search.tagFilter')} - -
- -
-
-
-
-
- */}
-
- - -
{isLoading && ( @@ -177,7 +130,7 @@ export default function Search() { )} - {total === 0 &&
+ {/* {!isLoading && total === 0 &&
-
} - {total > 0 && - { - setOpen('display'); - setSelectedEnvelope(envelope); - }} - setSortBy={setSortBy} - setSortOrder={setSortOrder} - /> - } +
} */} + { + setOpen('display'); + setSelectedEnvelope(envelope); + }} + setSortBy={setSortBy} + setSortOrder={setSortOrder} + /> {total > 0 && page < totalPages} @@ -246,14 +197,7 @@ export default function Search() { open={open === 'restore'} onOpenChange={() => setOpen('restore')} /> - - setOpen('columns')} - /> - - +
); diff --git a/web/src/features/search/mail-list-table.tsx b/web/src/features/search/mail-list-table.tsx index c8dbb74..c6062bb 100755 --- a/web/src/features/search/mail-list-table.tsx +++ b/web/src/features/search/mail-list-table.tsx @@ -19,7 +19,7 @@ import { dateFnsLocaleMap, formatBytes } from "@/lib/utils" import { format, formatDistanceToNow } from "date-fns" -import { Paperclip } from "lucide-react" +import { Badge, MessageSquareText, Paperclip } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton" import { Checkbox } from "@/components/ui/checkbox" import { EmailEnvelope } from "@/api" @@ -32,208 +32,302 @@ import LongText from "@/components/long-text" import { DataTableColumnHeader } from "./table/data-table-column-header" import { SearchTable } from "./table/table" import { DataTableRowActions } from "./table/data-table-row-actions" -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { DataTableToolbar } from "./table/toolbar" +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card" interface MailListProps { - items: EmailEnvelope[] - isLoading: boolean - onEnvelopeChanged: (envelope: EmailEnvelope) => void - setSortBy: (sortBy: "DATE" | "SIZE") => void - setSortOrder: (value: "desc" | "asc") => void + items: EmailEnvelope[] + isLoading: boolean + onEnvelopeChanged: (envelope: EmailEnvelope) => void + setSortBy: (sortBy: "DATE" | "SIZE") => void + setSortOrder: (value: "desc" | "asc") => void } export function MailListTable({ - items, - isLoading, - onEnvelopeChanged, - setSortBy, - setSortOrder + items, + isLoading, + onEnvelopeChanged, + setSortBy, + setSortOrder }: MailListProps) { - const { t, i18n } = useTranslation() + const { t, i18n } = useTranslation() - const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS - const { selected, setSelected } = useSearchContext() + const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS + const { selected, setSelected } = useSearchContext() - const columns: ColumnDef[] = [ - { - accessorKey: "id", - header: () => ( - 0 - ? true - : totalSelected > 0 - ? "indeterminate" - : false - } - onCheckedChange={handleToggleAll} - className="h-4 w-4" - /> - ), - cell: ({ row }) => ( - toggleSelected(row.original.account_id, row.original.id)} - onClick={(e) => e.stopPropagation()} - className="h-4 w-4 shrink-0" - /> - ), - meta: { className: 'text-left text-sm' }, - minSize: 25, - maxSize: 25, - }, - { - accessorKey: "account_email", - header: t('search.account'), - cell: ({ row }) => {row.original.account_email}, - meta: { className: 'text-left text-sm' }, - minSize: 166 - }, - { - accessorKey: "mailbox_name", - header: t('search.mailbox'), - cell: ({ row }) => {row.original.mailbox_name}, - meta: { className: 'text-left text-sm' }, - minSize: 116, - maxSize: 116, - }, - { - accessorKey: "from", - header: t('search.from'), - cell: ({ row }) => {row.original.from}, - meta: { className: 'text-left text-sm' }, - minSize: 150, - }, - { - accessorKey: "to", - header: t('search.to'), - cell: ({ row }) => {row.original.to.join(", ")}, - meta: { className: 'text-left text-sm' }, - }, - { - accessorKey: "subject", - header: t('search.subject'), - cell: ({ row }) => {row.original.subject}, - meta: { className: 'text-left text-sm' }, - size: 1000 - }, - { - id: "attachment_count", - header: () => , - cell: ({ row }) => {(row.original.attachments ?? []).length}, - meta: { className: 'text-left text-sm' }, - minSize: 40, - maxSize: 40 - }, - { - accessorKey: 'size', - header: ({ column }) => ( - - ), - cell: ({ row }) => {formatBytes(row.original.size)}, - meta: { className: 'text-left text-sm' }, - minSize: 100, - maxSize: 100, - }, - { - accessorKey: 'date', - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const date = new Date(row.original.date) - const title = format(date, 'yyyy-MM-dd HH:mm:ss') - return ( + const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: () => ( + 0 + ? true + : totalSelected > 0 + ? "indeterminate" + : false + } + onCheckedChange={handleToggleAll} + className="h-4 w-4" + /> + ), + cell: ({ row }) => ( + toggleSelected(row.original.account_id, row.original.id)} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4 shrink-0" + /> + ), + meta: { className: 'text-left text-sm' }, + minSize: 25, + maxSize: 25, + }, + { + accessorKey: "account_email", + header: t('search.account'), + cell: ({ row }) => {row.original.account_email}, + meta: { className: 'text-left text-xs' }, + minSize: 150, + maxSize: 156, + }, + { + accessorKey: "mailbox_name", + header: t('search.mailbox'), + cell: ({ row }) => { + const mailbox = row.original.mailbox_name + const tags = row.original.tags ?? [] + + if (!mailbox) return null + + const visible = tags.slice(0, 3) + const rest = tags.length - visible.length + + const fullTags = tags.join(' · ') + + return ( + - - {formatDistanceToNow(date, { addSuffix: true, locale })} - +
+ + {mailbox} + + + {visible.length > 0 && ( + + {visible.join(' · ')} + {rest > 0 && ` · +${rest}`} + + )} +
- {title} + + +
+ {mailbox} +
+ +
+ {fullTags} +
+
- ) - }, - meta: { className: 'text-left text-sm' }, - minSize: 100, +
+ ) }, - { - id: 'actions', - header: t('users.columns.actions'), - cell: DataTableRowActions, - minSize: 70, - maxSize: 70, + meta: { className: 'text-left text-xs' }, + minSize: 116, + maxSize: 116, + }, + { + accessorKey: "from", + header: t('search.from'), + cell: ({ row }) => {row.original.from}, + meta: { className: 'text-left text-xs' }, + minSize: 150, + maxSize: 156, + }, + { + accessorKey: "to", + header: t('search.to'), + cell: ({ row }) => {row.original.to.join(", ")}, + meta: { className: 'text-left text-xs' }, + minSize: 150, + maxSize: 156, + }, + { + accessorKey: "subject", + header: t('search.subject'), + cell: ({ row }) => {row.original.subject}, + meta: { className: 'text-left text-xs' }, + minSize: 450, + maxSize: 456, + }, + { + id: "text_preview", + header: () => null, + cell: ({ row }) => { + const text = row.original.text + + if (!text) return null + + return ( + + + + + + + {text} + + + ) }, - ] + meta: { className: "text-center max-w-[80px]" }, + minSize: 36, + maxSize: 36, + enableSorting: false, + }, + { + id: "attachment_count", + header: () => , + cell: ({ row }) => {(row.original.attachments ?? []).length}, + meta: { className: 'text-left text-xs' }, + minSize: 40, + maxSize: 40 + }, + { + accessorKey: 'size', + header: ({ column }) => ( + + ), + cell: ({ row }) => {formatBytes(row.original.size)}, + meta: { className: 'text-left text-xs' }, + minSize: 100, + maxSize: 100, + }, + { + accessorKey: 'date', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const date = new Date(row.original.date) + const title = format(date, 'yyyy-MM-dd HH:mm:ss') + return ( + + + + {formatDistanceToNow(date, { addSuffix: true, locale })} + + + {title} + + ) + }, + meta: { className: 'text-left text-xs' }, + minSize: 100, + maxSize: 100, + }, + { + id: 'actions', + header: t('users.columns.actions'), + cell: DataTableRowActions, + meta: { className: 'text-left text-xs' }, + minSize: 50, + maxSize: 60, + }, + ] - const handleToggleAll = () => { - const total = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0) + const handleToggleAll = () => { + const total = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0) - if (total === items.length && items.length > 0) { - setSelected(new Map()) - } else { - setSelected(prev => { - const next = new Map(prev) - for (const item of items) { - const set = new Set(next.get(item.account_id) || []) - set.add(item.id) - next.set(item.account_id, set) - } - return next - }) - } - } - - const toggleSelected = (accountId: number, mailId: number) => { + if (total === items.length && items.length > 0) { + setSelected(new Map()) + } else { setSelected(prev => { const next = new Map(prev) - const set = new Set(next.get(accountId) || []) - - if (set.has(mailId)) { - set.delete(mailId) - if (set.size === 0) next.delete(accountId) - else next.set(accountId, set) - } else { - set.add(mailId) - next.set(accountId, set) + for (const item of items) { + const set = new Set(next.get(item.account_id) || []) + set.add(item.id) + next.set(item.account_id, set) } return next }) } + } - const totalSelected = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0) + const toggleSelected = (accountId: number, mailId: number) => { + setSelected(prev => { + const next = new Map(prev) + const set = new Set(next.get(accountId) || []) - const hasSelected = (accountId: number, mailId: number) => selected.get(accountId)?.has(mailId) ?? false + if (set.has(mailId)) { + set.delete(mailId) + if (set.size === 0) next.delete(accountId) + else next.set(accountId, set) + } else { + set.add(mailId) + next.set(accountId, set) + } + return next + }) + } - if (isLoading) { - return ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- - - - -
- ))} -
- ) - } + const totalSelected = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0) + const hasSelected = (accountId: number, mailId: number) => selected.get(accountId)?.has(mailId) ?? false + + if (isLoading) { return ( - <> - { - const target = e.target as HTMLElement - if (target.closest('input[type="checkbox"], button')) return - onEnvelopeChanged(row.original) - }} - setSortBy={setSortBy} - setSortOrder={setSortOrder} - /> - {totalSelected > 0 && } - +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + + +
+ ))} +
) + } + + return ( + <> + { + const target = e.target as HTMLElement + if (target.closest('input[type="checkbox"], button')) return + onEnvelopeChanged(row.original) + }} + setSortBy={setSortBy} + setSortOrder={setSortOrder} + > + {(table) => { + return + }} + + + {totalSelected > 0 && } + + ) } diff --git a/web/src/features/search/mailbox-popover.tsx b/web/src/features/search/mailbox-popover.tsx new file mode 100644 index 0000000..66b19a7 --- /dev/null +++ b/web/src/features/search/mailbox-popover.tsx @@ -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(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() + + 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 ( + + + + + + +
+ setSearch(e.target.value)} + placeholder="Search mailbox" + className="h-8 text-sm" + /> +
+ {selectedMailboxIds.length > 0 && ( +
+ +
+ )} + + {disabled ? ( +

+ Please select account first +

+ ) : isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : grouped.length === 0 ? ( +

+ No mailbox found +

+ ) : ( + + {grouped.map(([accountId, boxes]) => { + const selectedCount = boxes.filter(b => + selectedMailboxIds.includes(b.id) + ).length + + return ( + + + + {getAccountEmail(accountId)} + + + {selectedCount > 0 && ( + + {selectedCount} + + )} + + + +
+ {boxes.map(mailbox => { + const checked = + selectedMailboxIds.includes(mailbox.id) + + return ( + + + +
+ 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' + )} + > + + toggleMailbox(mailbox.id) + } + onClick={e => + e.stopPropagation() + } + /> + + + {mailbox.name} + +
+
+ + +
+ {mailbox.name} +
+
+
+
+ ) + })} +
+
+
+ ) + })} +
+ )} + + + + ) +} diff --git a/web/src/features/search/more-filters-popover.tsx b/web/src/features/search/more-filters-popover.tsx new file mode 100644 index 0000000..d553ba4 --- /dev/null +++ b/web/src/features/search/more-filters-popover.tsx @@ -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 ( + + + + + + +
+

Advanced Filters

+ {activeCount > 0 && ( + + )} +
+ +
+ + setLocalState(prev => ({ ...prev, has_attachment: checked as boolean })) + } + /> + +
+ +
+ + setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))} + placeholder="e.g. invoice.pdf" + /> +
+ +
+ + +
+ +
+ + setLocalState(prev => ({ ...prev, message_id: e.target.value }))} + /> +

+ {t('search.originalMessageIdHeader')} +

+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/web/src/features/search/search-form.tsx b/web/src/features/search/search-form.tsx index 7e3569f..78ad6c4 100644 --- a/web/src/features/search/search-form.tsx +++ b/web/src/features/search/search-form.tsx @@ -388,7 +388,6 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
{showAdvanced && - {/* Sender & Recipients */} {t('search.sender')} / {t('search.recipient')} diff --git a/web/src/features/search/table/table.tsx b/web/src/features/search/table/table.tsx index c10b26f..d37894f 100755 --- a/web/src/features/search/table/table.tsx +++ b/web/src/features/search/table/table.tsx @@ -31,8 +31,9 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table' +import { type Table } from '@tanstack/react-table' import { - Table, + Table as ShadcnTable, TableBody, TableCell, TableHead, @@ -43,6 +44,9 @@ import { useTranslation } from 'react-i18next' import { EmailEnvelope } from '@/api' import { cn } from '@/lib/utils' import { useSearchContext } from '../context' +import { ScrollArea } from '@/components/ui/scroll-area' + + declare module '@tanstack/react-table' { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -57,10 +61,11 @@ interface DataTableProps { onRowClick: (e: ReactMouseEvent, row: Row) => void setSortBy: (sortBy: "DATE" | "SIZE") => void setSortOrder: (value: "desc" | "asc") => void + children?: (table: Table) => React.ReactNode } -export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder }: DataTableProps) { - const { sorting, setSorting, columnVisibility, setColumnVisibility } = useSearchContext() +export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder, children }: DataTableProps) { + const { sorting, setSorting } = useSearchContext() const { t } = useTranslation() const [rowSelection, setRowSelection] = useState({}) const [columnFilters, setColumnFilters] = useState([]) @@ -76,7 +81,6 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder columns, state: { sorting, - columnVisibility, rowSelection, columnFilters, }, @@ -84,7 +88,6 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder onRowSelectionChange: setRowSelection, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, - onColumnVisibilityChange: setColumnVisibility, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), @@ -93,9 +96,10 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder }) return ( -
-
-
+
+ {children && (<>{children(table)})} + + {table.getHeaderGroups().map((headerGroup) => ( @@ -131,7 +135,7 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder )} + -
-
+ +
) } diff --git a/web/src/features/search/table/toolbar.tsx b/web/src/features/search/table/toolbar.tsx new file mode 100644 index 0000000..a3f3cf6 --- /dev/null +++ b/web/src/features/search/table/toolbar.tsx @@ -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 = { + table: Table +} + +export function DataTableToolbar({ + table, +}: DataTableToolbarProps) { + + + + return ( +
+
+ +
+
+
+ + + + + + +
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/web/src/features/search/table/view-options.tsx b/web/src/features/search/table/view-options.tsx new file mode 100644 index 0000000..6cca43e --- /dev/null +++ b/web/src/features/search/table/view-options.tsx @@ -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 = { + table: Table +} + +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({ + table, +}: DataTableViewOptionsProps) { + 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 ( + + + + + + Toggle columns + + {table + .getAllColumns() + .filter(column => visibleColumnKeys.has(column.id)) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {columnLabels[column.id] ?? column.id} + + ) + })} + + + ) +} diff --git a/web/src/features/search/tag-filter-popover.tsx b/web/src/features/search/tag-filter-popover.tsx new file mode 100644 index 0000000..120da59 --- /dev/null +++ b/web/src/features/search/tag-filter-popover.tsx @@ -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 ( + + + + + + +
+ setSearch(e.target.value)} + placeholder={t('mail.searchTags')} + className="h-8 text-sm" + autoFocus + /> +
+ + {!search && selectedTags.length > 0 && ( + <> +
+
+ +
+ + {t('common.clear_all_tags')} + + ({selectedTags.length}) +
+
+ + )} + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : filteredTags.length === 0 ? ( +

+ {t('mail.noTagsFound')} +

+ ) : ( + filteredTags.map(({ tag, count }) => { + const checked = selectedTags.includes(tag) + const id = `tag-${tag}` + + return ( +
handleTagToggle(tag)} + className={cn( + 'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer', + 'hover:bg-accent transition-colors' + )} + > + + handleTagToggle(tag) + } + onClick={(e) => + e.stopPropagation() + } + /> + + + + + {count} + +
+ ) + }) + )} + + + + ) +} diff --git a/web/src/features/search/text-search-input.tsx b/web/src/features/search/text-search-input.tsx new file mode 100644 index 0000000..0a5d9e6 --- /dev/null +++ b/web/src/features/search/text-search-input.tsx @@ -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([]) + const [showHistory, setShowHistory] = useState(false) + const inputRef = useRef(null) + const containerRef = useRef(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) => { + 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 ( +
+
+
+ + 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 && ( + + )} +
+ + +
+ + {showHistory && ( +
+
+
+ + Recent searches +
+ {history.length > 0 && ( + + )} +
+ + {history.length > 0 ? ( + history.map((term, idx) => ( + + )) + ) : ( +
+ No recent searches +
+ )} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/web/src/features/search/time-popover.tsx b/web/src/features/search/time-popover.tsx new file mode 100644 index 0000000..85fe02a --- /dev/null +++ b/web/src/features/search/time-popover.tsx @@ -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('') + + 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 ( + + + + + + +
+
+
+ {[1, 7, 30].map(d => ( + setRange(Date.now() - d * DAY, undefined)}> + Last {d === 1 ? 'day' : `${d} days`} + + ))} + {[3, 6].map(m => ( + setRange(Date.now() - m * 30 * DAY, undefined)}> + Last {m} months + + ))} +
+ +
+ Recent: + setCustomDays(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleApplyRecent()} + /> + days ago to now + +
+
+
+
+
+ {[1, 2, 3, 5, 10].map(y => ( + 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 + + ))} +
+
+
+
+
+ SINCE + setSince(date?.getTime())} + /> +
+
+ BEFORE + setBefore(date?.getTime())} + /> +
+
+
+ + {(since || before) && ( +
+ +
+ )} +
+
+ ) +} + +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 ( +
+
+ {title} +
+ {children} +
+ ) +} + +function Quick({ + children, + onClick, + className +}: { + children: React.ReactNode; + onClick: () => void; + className?: string +}) { + return ( + + ) +} \ No newline at end of file diff --git a/web/src/hooks/use-available-tags.ts b/web/src/hooks/use-available-tags.ts index 1ca874d..a8b482d 100644 --- a/web/src/hooks/use-available-tags.ts +++ b/web/src/hooks/use-available-tags.ts @@ -17,7 +17,7 @@ // along with this program. If not, see . -import { get_top_tags } from '@/api/search/api'; +import { get_tags } from '@/api/search/api'; import { useQuery } from '@tanstack/react-query'; import React from 'react'; @@ -45,7 +45,7 @@ export function useAvailableTags(): UseAvailableTagsResult { refetch, } = useQuery({ queryKey: ['all-tags'], - queryFn: get_top_tags, + queryFn: get_tags, staleTime: 60 * 1000, retry: false, refetchOnWindowFocus: false, diff --git a/web/src/hooks/use-contacts.ts b/web/src/hooks/use-contacts.ts new file mode 100644 index 0000000..bb7508b --- /dev/null +++ b/web/src/hooks/use-contacts.ts @@ -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 + }; +}; \ No newline at end of file diff --git a/web/src/hooks/use-search-messages.ts b/web/src/hooks/use-search-messages.ts index 5b76bd4..983e5bb 100644 --- a/web/src/hooks/use-search-messages.ts +++ b/web/src/hooks/use-search-messages.ts @@ -92,6 +92,7 @@ export function useSearchMessages() { setPage, onSubmit, reset, - filter + filter, + setFilter }; } \ No newline at end of file diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 98c1a3d..8367b9f 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -47,8 +47,7 @@ "op_failed": "Operation failed", "na": "N/A", "retry": "Retry", - "deleting": "Deleting...", - "columns": "Columns" + "deleting": "Deleting..." }, "navigation": { "home": "Home", @@ -1463,4 +1462,4 @@ "failed": "Failed to restore messages", "failedTitle": "Restore Failed" } -} +} \ No newline at end of file