diff --git a/web/src/components/pagination.tsx b/web/src/components/pagination.tsx index 04b9298..668e2ae 100644 --- a/web/src/components/pagination.tsx +++ b/web/src/components/pagination.tsx @@ -24,6 +24,7 @@ import { DoubleArrowRightIcon, } from '@radix-ui/react-icons' import { Button } from '@/components/ui/button' +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -33,6 +34,7 @@ import { } from '@/components/ui/select' import { useTranslation } from 'react-i18next' import { showNumbers } from '@/lib/utils' +import { useEffect, useState } from 'react'; interface PaginationProps { totalItems: number @@ -52,8 +54,13 @@ export function EnvelopeListPagination({ setPageSize, }: PaginationProps) { const { t } = useTranslation() + const [pageInput, setPageInput] = useState(pageIndex + 1); const pageCount = Math.ceil(totalItems / pageSize) + useEffect(() => { + setPageInput(pageIndex + 1) + }, [pageIndex]) + const handlePageSizeChange = (value: string) => { const newPageSize = Number(value) setPageSize(newPageSize) @@ -97,7 +104,16 @@ export function EnvelopeListPagination({
- {t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount} + {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' + /> + {t("table.of")} {pageCount}
+ +
+ + + + ); +} diff --git a/web/src/features/search/context/index.tsx b/web/src/features/search/context/index.tsx index f8e5150..3698646 100644 --- a/web/src/features/search/context/index.tsx +++ b/web/src/features/search/context/index.tsx @@ -19,8 +19,9 @@ import React from 'react' import { EmailEnvelope } from '@/api' +import { SortingState, VisibilityState } from '@tanstack/react-table' -export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore' +export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore' | 'columns' interface SearchContextType { open: SearchDialogType | null @@ -32,6 +33,10 @@ interface SearchContextType { selected: Map> setSelected: React.Dispatch>>> selectedTags: string[] + sorting: SortingState + setSorting: React.Dispatch> + columnVisibility: VisibilityState + setColumnVisibility: React.Dispatch> } const SearchContext = React.createContext(null) diff --git a/web/src/features/search/index.tsx b/web/src/features/search/index.tsx index 876678e..a5cf1e4 100644 --- a/web/src/features/search/index.tsx +++ b/web/src/features/search/index.tsx @@ -23,10 +23,9 @@ import { Main } from '@/components/layout/main'; import { useSearchMessages } from '@/hooks/use-search-messages'; import { SearchFormDialog } from './search-form'; import { EnvelopeListPagination } from '@/components/pagination'; -import { MailList } from './mail-list'; import React from 'react'; import { EmailEnvelope } from '@/api'; -import { ArrowDownWideNarrow, ArrowUpWideNarrow, Filter, SearchIcon } from 'lucide-react'; +import { Filter, SearchIcon, SquarePen } from 'lucide-react'; import { MailDisplayDrawer } from './mail-display-dialog'; import { EnvelopeDeleteDialog } from './delete-dialog'; import SearchProvider, { SearchDialogType } from './context'; @@ -39,8 +38,9 @@ import { EditTagsDialog } from './add-tag-dialog'; import { useTranslation } from 'react-i18next'; import Logo from '@/assets/logo.svg' import { RestoreMessageDialog } from './restore-message-dialog'; -import { Separator } from '@/components/ui/separator'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { ColumnsDialog } from './columns-dialog'; +import { MailListTable } from './mail-list-table'; +import { SortingState, VisibilityState } from '@tanstack/react-table'; export default function Search() { const { t } = useTranslation() @@ -49,6 +49,11 @@ export default function Search() { const [toDelete, setToDelete] = React.useState>>(new Map()); 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, @@ -58,8 +63,6 @@ export default function Search() { isFetching, page, pageSize, - sortBy, - sortOrder, setPage, setPageSize, setSortBy, @@ -92,7 +95,23 @@ export default function Search() { <>
- +
@@ -127,7 +146,7 @@ export default function Search() {
-
+
-
- - {t('search.sort')} - - - value && setSortBy(value as "DATE" | "SIZE")} - className="gap-1" - > - - {t('search.date')} - - - {t('search.size')} - - - - -
+
{isLoading && ( @@ -204,14 +192,16 @@ export default function Search() {

} - {total > 0 && - 0 && + { setOpen('display'); setSelectedEnvelope(envelope); }} + setSortBy={setSortBy} + setSortOrder={setSortOrder} /> } {total > 0 && setOpen('restore')} /> - + + setOpen('columns')} + /> + +
); diff --git a/web/src/features/search/mail-list-table.tsx b/web/src/features/search/mail-list-table.tsx new file mode 100755 index 0000000..5bfbcfc --- /dev/null +++ b/web/src/features/search/mail-list-table.tsx @@ -0,0 +1,242 @@ +// +// 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 { dateFnsLocaleMap, formatBytes } from "@/lib/utils" +import { format, formatDistanceToNow } from "date-fns" +import { Paperclip } from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" +import { Checkbox } from "@/components/ui/checkbox" +import { EmailEnvelope } from "@/api" +import { useSearchContext } from "./context" +import { MailBulkActions } from "./bulk-actions" +import { useTranslation } from 'react-i18next' +import { enUS } from "date-fns/locale" +import { ColumnDef } from "@tanstack/react-table" +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'; + +interface MailListProps { + 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 +}: MailListProps) { + const { t, i18n } = useTranslation() + + 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 ( + + + + {formatDistanceToNow(date, { addSuffix: true, locale })} + + + {title} + + ) + }, + meta: { className: 'text-left text-sm' }, + minSize: 100, + }, + { + id: 'actions', + header: t('users.columns.actions'), + cell: DataTableRowActions, + minSize: 70, + maxSize: 70, + }, + ] + + 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) => { + 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); + } + + return next; + }); + } + + 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 ( +
+ {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} + /> + {totalSelected > 0 && } + + ) +} diff --git a/web/src/features/search/table/data-table-column-header.tsx b/web/src/features/search/table/data-table-column-header.tsx new file mode 100755 index 0000000..7de52cf --- /dev/null +++ b/web/src/features/search/table/data-table-column-header.tsx @@ -0,0 +1,83 @@ +// +// 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 { + ArrowDownIcon, + ArrowUpIcon, + CaretSortIcon, +} from '@radix-ui/react-icons' +import { Column } from '@tanstack/react-table' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useTranslation } from 'react-i18next' + +interface DataTableColumnHeaderProps + extends React.HTMLAttributes { + column: Column + title: string +} + +export function DataTableColumnHeader({ + column, + title, + className, +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
+ } + const { t } = useTranslation() + return ( +
+ + + + + + column.toggleSorting(false)}> + + {t('table.asc')} + + column.toggleSorting(true)}> + + {t('table.desc')} + + + +
+ ) +} diff --git a/web/src/features/search/table/data-table-row-actions.tsx b/web/src/features/search/table/data-table-row-actions.tsx new file mode 100755 index 0000000..f4c1531 --- /dev/null +++ b/web/src/features/search/table/data-table-row-actions.tsx @@ -0,0 +1,124 @@ +// +// 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 { Row } from '@tanstack/react-table' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useTranslation } from 'react-i18next' +import { MoreVertical, TagIcon, Trash2 } from 'lucide-react' +import { EmailEnvelope } from '@/api' +import { useSearchContext } from '../context' + +interface DataTableRowActionsProps { + row: Row +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const { setOpen, setCurrentEnvelope, setToDelete } = useSearchContext() + const { t } = useTranslation() + + const toggleToDelete = (accountId: number, mailId: number) => { + setToDelete(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); + } + + return next; + }); + }; + + const handleDelete = (envelope: EmailEnvelope) => { + setToDelete(new Map()); + toggleToDelete(envelope.account_id, envelope.id) + setOpen("delete") + } + + return ( + <> + + + + + + { + e.stopPropagation(); + setCurrentEnvelope(row.original); + setOpen("edit-tags"); + }} + > + {t('search.editTag')} + + + + + + { + e.stopPropagation(); + setCurrentEnvelope(row.original); + setOpen("restore"); + }} + > + {t('restore_message.restore_to_imap', 'Restore Mail')} + + + + + + { + e.stopPropagation(); + handleDelete(row.original); + //setCurrentRow(row.original) + //setOpen('delete') + }} + className='!text-red-500' + > + {t('common.delete')} + + + + + + + + ) +} diff --git a/web/src/features/search/table/table.tsx b/web/src/features/search/table/table.tsx new file mode 100755 index 0000000..945ecf1 --- /dev/null +++ b/web/src/features/search/table/table.tsx @@ -0,0 +1,163 @@ +// +// 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 { useState, MouseEvent as ReactMouseEvent, useEffect } from 'react' +import { + ColumnDef, + ColumnFiltersState, + Row, + RowData, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { useTranslation } from 'react-i18next' +import { EmailEnvelope } from '@/api' +import { cn } from '@/lib/utils' +import { useSearchContext } from '../context' + +declare module '@tanstack/react-table' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + className: string + } +} + +interface DataTableProps { + columns: ColumnDef[] + data: EmailEnvelope[] + onRowClick: (e: ReactMouseEvent, row: Row) => void; + setSortBy: (sortBy: "DATE" | "SIZE") => void + setSortOrder: (value: "desc" | "asc") => void +} + +export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder }: DataTableProps) { + const { sorting, setSorting, columnVisibility, setColumnVisibility } = useSearchContext() + const { t } = useTranslation() + const [rowSelection, setRowSelection] = useState({}) + const [columnFilters, setColumnFilters] = useState([]) + + useEffect(() => { + const [value] = sorting; + setSortBy(value.id.toUpperCase() as "DATE" | "SIZE"); + setSortOrder(value.desc ? "desc" : "asc"); + }, [sorting]) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }) + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + onRowClick(e, row)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t('common.table.noResults')} + + + )} + +
+
+
+ ) +} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 9603ec2..7d27784 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -47,7 +47,8 @@ "op_failed": "Operation failed", "na": "N/A", "retry": "Retry", - "deleting": "Deleting..." + "deleting": "Deleting...", + "columns": "Columns" }, "navigation": { "home": "Home", @@ -1462,4 +1463,4 @@ "failed": "Failed to restore messages", "failedTitle": "Restore Failed" } -} \ No newline at end of file +}