Files
bichon/web/src/features/search/index.tsx
T

261 lines
9.5 KiB
TypeScript
Raw Normal View History

2025-11-19 02:14:37 +08:00
//
// 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 { Card, CardContent } from '@/components/ui/card';
import { FixedHeader } from '@/components/layout/fixed-header';
import { Main } from '@/components/layout/main';
import { useSearchMessages } from '@/hooks/use-search-messages';
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';
2025-11-19 02:14:37 +08:00
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';
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';
2025-11-19 02:14:37 +08:00
export default function Search() {
const { t } = useTranslation()
2025-11-19 02:14:37 +08:00
const [selectedEnvelope, setSelectedEnvelope] = React.useState<EmailEnvelope | undefined>(undefined);
const [open, setOpen] = useDialogState<SearchDialogType>(null)
const [toDelete, setToDelete] = 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 [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>
: {}
)
2025-11-19 02:14:37 +08:00
const {
emails,
total,
totalPages,
isLoading,
isFetching,
page,
pageSize,
setPage,
setPageSize,
setSortBy,
setSortOrder,
2025-11-19 02:14:37 +08:00
onSubmit,
reset,
filter
} = useSearchMessages();
const handleSetPageSize = (pageSize: number) => {
setPage(1);
setPageSize(pageSize)
}
const handleReset = () => {
reset();
setSelectedTags([]);
};
const handleTagToggle = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag)
? prev.filter(t => t !== tag)
: [...prev, tag]
);
};
return (
<>
<FixedHeader />
<Main>
<SearchProvider
value={{
open,
setOpen,
currentEnvelope: selectedEnvelope,
selectedTags,
setCurrentEnvelope: setSelectedEnvelope,
toDelete,
setToDelete,
selected,
setSelected,
sorting,
setSorting,
columnVisibility,
setColumnVisibility
}}
>
2025-11-19 02:14:37 +08:00
<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')}
2025-11-19 02:14:37 +08:00
{selectedTags.length > 0 && ` (${selectedTags.length})`}
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80">
<SheetHeader>
<SheetTitle>{t('search.tagFilter')}</SheetTitle>
2025-11-19 02:14:37 +08:00
</SheetHeader>
<div className="mt-6">
<EnvelopeTags
selectedTags={selectedTags}
onTagToggle={handleTagToggle}
/>
</div>
</SheetContent>
</Sheet>
</div>
<div className="flex gap-6">
<aside className="hidden lg:block w-64 flex-shrink-0">
<div className="rounded-lg border bg-card p-4">
<EnvelopeTags
selectedTags={selectedTags}
onTagToggle={handleTagToggle}
/>
</div>
</aside>
<div className="flex-1 min-w-0 space-y-4">
<div className="flex flex-row gap-4 items-end">
<Button
size="sm"
variant="default"
onClick={() => setOpen("search-form")}
className="px-4 shadow-sm"
>
<SearchIcon className="mr-2 h-4 w-4" />
{t('common.search')}
</Button>
<Button
size="sm"
variant="default"
onClick={() => setOpen("columns")}
className="px-4 shadow-sm"
>
<SquarePen className="mr-2 h-4 w-4" />
{t('common.columns')}
</Button>
</div>
2025-11-19 02:14:37 +08:00
{isLoading && (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent"></div>
<p className="text-sm">{t('search.searching')}</p>
2025-11-19 02:14:37 +08:00
</div>
</CardContent>
</Card>
)}
{total === 0 && <div className="flex h-[750px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('search.noEmailsFound')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{Object.keys(filter).length === 0
? t('search.startSearching')
: t('search.adjustSearch')}
</p>
2025-11-19 02:14:37 +08:00
</div>
</div>}
{total > 0 && <ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1' orientation='both'>
<MailListTable
2025-11-19 02:14:37 +08:00
isLoading={isLoading}
items={emails}
onEnvelopeChanged={(envelope) => {
setOpen('display');
setSelectedEnvelope(envelope);
}}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
2025-11-19 02:14:37 +08:00
/>
</ScrollArea>}
{total > 0 && <EnvelopeListPagination
totalItems={total}
hasNextPage={() => page < totalPages}
pageIndex={page - 1}
pageSize={pageSize}
setPageIndex={(index) => setPage(index + 1)}
setPageSize={handleSetPageSize}
/>}
</div>
</div>
</div>
<MailDisplayDrawer
key='search-mail-display'
open={open === 'display'}
onOpenChange={() => setOpen('display')}
/>
<EnvelopeDeleteDialog
key='delete-envelope'
open={open === 'delete'}
onOpenChange={() => setOpen('delete')}
/>
<EditTagsDialog
key='edit-tags-dialog'
open={open === 'edit-tags'}
onOpenChange={() => setOpen('edit-tags')}
2025-11-19 02:14:37 +08:00
/>
<SearchFormDialog
key='search-form-dialog'
onSubmit={onSubmit} isLoading={isLoading || isFetching} reset={handleReset}
open={open === 'search-form'}
onOpenChange={() => setOpen('search-form')}
/>
<RestoreMessageDialog
key='restore-mail-dialog'
open={open === 'restore'}
onOpenChange={() => setOpen('restore')}
/>
<ColumnsDialog
key='columns-dialog'
open={open === 'columns'}
onOpenChange={() => setOpen('columns')}
/>
</SearchProvider>
2025-11-19 02:14:37 +08:00
</Main>
</>
);
}