From b490923e17229ac90473a0cba7d7ba57b6a7df01 Mon Sep 17 00:00:00 2001 From: rustmailer Date: Wed, 7 Jan 2026 16:40:45 +0800 Subject: [PATCH] refactor(search): search filtering and sorting --- src/modules/indexer/manager.rs | 48 ++++++++-------- src/modules/message/search.rs | 16 ++++-- web/package.json | 2 + web/pnpm-lock.yaml | 6 ++ web/src/components/ui/toggle-group.tsx | 61 ++++++++++++++++++++ web/src/components/ui/toggle.tsx | 43 ++++++++++++++ web/src/features/search/index.tsx | 74 +++++++++++++++++-------- web/src/features/search/search-form.tsx | 1 + web/src/hooks/use-search-messages.ts | 10 +++- web/src/locales/ar.json | 2 + web/src/locales/da.json | 2 + web/src/locales/de.json | 2 + web/src/locales/en.json | 2 +- web/src/locales/es.json | 2 + web/src/locales/fi.json | 2 +- web/src/locales/fr.json | 2 + web/src/locales/it.json | 2 + web/src/locales/jp.json | 2 + web/src/locales/ko.json | 2 + web/src/locales/nl.json | 2 + web/src/locales/no.json | 2 + web/src/locales/pl.json | 2 + web/src/locales/pt.json | 2 + web/src/locales/ru.json | 2 + web/src/locales/sv.json | 2 + web/src/locales/zh-tw.json | 2 + web/src/locales/zh.json | 2 + 27 files changed, 241 insertions(+), 56 deletions(-) create mode 100644 web/src/components/ui/toggle-group.tsx create mode 100644 web/src/components/ui/toggle.tsx diff --git a/src/modules/indexer/manager.rs b/src/modules/indexer/manager.rs index 8fb5692..6692515 100644 --- a/src/modules/indexer/manager.rs +++ b/src/modules/indexer/manager.rs @@ -24,7 +24,7 @@ use std::{ time::Duration, }; -use crate::modules::message::tags::TagCount; +use crate::modules::message::{search::SortBy, tags::TagCount}; use crate::{ modules::{ account::migration::AccountModel, @@ -621,7 +621,7 @@ impl EnvelopeIndexManager { page: u64, page_size: u64, desc: bool, - sort_by: String + sort_by: SortBy, ) -> BichonResult> { assert!(page > 0, "Page number must be greater than 0"); assert!(page_size > 0, "Page size must be greater than 0"); @@ -656,26 +656,29 @@ impl EnvelopeIndexManager { let order = if desc { Order::Desc } else { Order::Asc }; let mailbox_docs: Vec; - if sort_by == "size" { - let size_docs: Vec<(u64, DocAddress)> = searcher - .search( - &query, - &TopDocs::with_limit(page_size as usize) - .and_offset(offset as usize) - .order_by_fast_field(F_SIZE, order), - ) - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; - mailbox_docs = size_docs.into_iter().map(|(_, addr)| addr).collect(); - } else { - let date_docs: Vec<(i64, DocAddress)> = searcher - .search( - &query, - &TopDocs::with_limit(page_size as usize) - .and_offset(offset as usize) - .order_by_fast_field(F_DATE, order), - ) - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; - mailbox_docs = date_docs.into_iter().map(|(_, addr)| addr).collect(); + match sort_by { + SortBy::DATE => { + let date_docs: Vec<(i64, DocAddress)> = searcher + .search( + &query, + &TopDocs::with_limit(page_size as usize) + .and_offset(offset as usize) + .order_by_fast_field(F_DATE, order), + ) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + mailbox_docs = date_docs.into_iter().map(|(_, addr)| addr).collect(); + } + SortBy::SIZE => { + let size_docs: Vec<(u64, DocAddress)> = searcher + .search( + &query, + &TopDocs::with_limit(page_size as usize) + .and_offset(offset as usize) + .order_by_fast_field(F_SIZE, order), + ) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + mailbox_docs = size_docs.into_iter().map(|(_, addr)| addr).collect(); + } } let mut result = Vec::new(); @@ -866,7 +869,6 @@ impl EnvelopeIndexManager { } } - pub async fn top_10_largest_emails( &self, accounts: &Option>, diff --git a/src/modules/message/search.rs b/src/modules/message/search.rs index f258a94..e6c2400 100644 --- a/src/modules/message/search.rs +++ b/src/modules/message/search.rs @@ -18,7 +18,7 @@ use std::collections::HashSet; -use poem_openapi::Object; +use poem_openapi::{Enum, Object}; use serde::{Deserialize, Serialize}; use crate::{ @@ -49,12 +49,20 @@ pub struct SearchFilter { pub tags: Option>, } +#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)] +pub enum SortBy { + #[default] + DATE, + SIZE, +} + #[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)] pub struct SearchRequest { filter: SearchFilter, page: u64, page_size: u64, - sort_by: String + sort_by: Option, + desc: Option, } impl SearchRequest { pub fn validate(&self) -> BichonResult<()> { @@ -85,8 +93,8 @@ pub async fn search_messages_impl( request.filter, request.page, request.page_size, - true, - request.sort_by + request.desc.unwrap_or(true), + request.sort_by.unwrap_or(SortBy::DATE), ) .await } diff --git a/web/package.json b/web/package.json index 4b50984..cf70ab7 100644 --- a/web/package.json +++ b/web/package.json @@ -39,6 +39,8 @@ "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-visually-hidden": "^1.1.0", "@react-spring/web": "^10.0.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index cf62009..faa53c7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -86,6 +86,12 @@ importers: '@radix-ui/react-toast': specifier: ^1.2.2 version: 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle-group': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': specifier: ^1.1.4 version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/web/src/components/ui/toggle-group.tsx b/web/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..1c876bb --- /dev/null +++ b/web/src/components/ui/toggle-group.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/web/src/components/ui/toggle.tsx b/web/src/components/ui/toggle.tsx new file mode 100644 index 0000000..ab0a30f --- /dev/null +++ b/web/src/components/ui/toggle.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/web/src/features/search/index.tsx b/web/src/features/search/index.tsx index e7e18d4..876678e 100644 --- a/web/src/features/search/index.tsx +++ b/web/src/features/search/index.tsx @@ -26,7 +26,7 @@ import { EnvelopeListPagination } from '@/components/pagination'; import { MailList } from './mail-list'; import React from 'react'; import { EmailEnvelope } from '@/api'; -import { Filter, SearchIcon } from 'lucide-react'; +import { ArrowDownWideNarrow, ArrowUpWideNarrow, Filter, SearchIcon } from 'lucide-react'; import { MailDisplayDrawer } from './mail-display-dialog'; import { EnvelopeDeleteDialog } from './delete-dialog'; import SearchProvider, { SearchDialogType } from './context'; @@ -39,14 +39,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 { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; export default function Search() { const { t } = useTranslation() @@ -65,9 +59,11 @@ export default function Search() { page, pageSize, sortBy, + sortOrder, setPage, setPageSize, setSortBy, + setSortOrder, onSubmit, reset, filter @@ -131,26 +127,56 @@ export default function Search() {
-
- - + + {t('search.date')} + + + {t('search.size')} + + + + +
{isLoading && ( diff --git a/web/src/features/search/search-form.tsx b/web/src/features/search/search-form.tsx index b057314..a38f2ab 100644 --- a/web/src/features/search/search-form.tsx +++ b/web/src/features/search/search-form.tsx @@ -457,6 +457,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang {t('search.small')} {t('search.medium')} {t('search.large')} + {t('search.huge')} diff --git a/web/src/hooks/use-search-messages.ts b/web/src/hooks/use-search-messages.ts index 3c5185e..5b76bd4 100644 --- a/web/src/hooks/use-search-messages.ts +++ b/web/src/hooks/use-search-messages.ts @@ -29,7 +29,8 @@ export function useSearchMessages() { const [filter, setFilter] = useState>({}); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(30); - const [sortBy, setSortBy] = useState<"date" | "size">("date"); + const [sortBy, setSortBy] = useState<"DATE" | "SIZE">("DATE"); + const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc"); const onSubmit = (cleaned: Record) => { if ('has_attachment' in cleaned && cleaned.has_attachment === false) { @@ -60,13 +61,14 @@ export function useSearchMessages() { error, isFetching, } = useQuery>({ - queryKey: ['search-messages', filter, page, pageSize, sortBy], + queryKey: ['search-messages', filter, page, pageSize, sortBy, sortOrder], queryFn: () => search_messages({ filter: filter, page, page_size: pageSize, - sort_by: sortBy + sort_by: sortBy, + desc: sortOrder === "desc" }), staleTime: 1000, retry: false, @@ -80,6 +82,8 @@ export function useSearchMessages() { setPageSize, sortBy, setSortBy, + sortOrder, + setSortOrder, isLoading, isError, error: error as Error | null, diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index dac9cf9..b202a0f 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -414,6 +414,8 @@ "small": "صغير (<2 ميغابايت)", "medium": "متوسط (2 - 10 ميغابايت)", "large": "كبير (10 - 20 ميغابايت)", + "huge": "ضخم (≥20 MB)", + "sort": "فرز", "sizeDescription": "يشير الحجم إلى الحجم الإجمالي للبريد الإلكتروني، بما في ذلك المرفقات.", "title": "بحث", "searching": "جارٍ البحث، يرجى الانتظار...", diff --git a/web/src/locales/da.json b/web/src/locales/da.json index bc6e2d0..327920a 100644 --- a/web/src/locales/da.json +++ b/web/src/locales/da.json @@ -414,6 +414,8 @@ "small": "Lille (<2 MB)", "medium": "Mellem (2 - 10 MB)", "large": "Stor (10 - 20 MB)", + "huge": "Kæmpestor (≥20 MB)", + "sort": "Sortér", "sizeDescription": "Størrelsen henviser til den samlede e-mailstørrelse, inklusive vedhæftede filer.", "title": "Søg", "searching": "Søger, vent venligst...", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 236a22a..6a08d8f 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -414,6 +414,8 @@ "small": "Klein (<2 MB)", "medium": "Mittel (2 - 10 MB)", "large": "Groß (10 - 20 MB)", + "huge": "Riesig (≥20 MB)", + "sort": "Sortieren", "sizeDescription": "Die Größe bezieht sich auf die gesamte E-Mail inklusive Anhängen.", "title": "Suchen", "searching": "Wird gesucht, bitte warten Sie...", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 4b2dea2..9603ec2 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -415,7 +415,7 @@ "medium": "Medium (2 - 10 MB)", "large": "Large (10 - 20 MB)", "huge": "Huge (≥20 MB)", - "sortBy": "Sort by", + "sort": "Sort", "sizeDescription": "The size refers to the total email size, including attachments.", "title": "Search", "searching": "Searching, please wait…", diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 4faa675..1131ba9 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -414,6 +414,8 @@ "small": "Pequeño (<2 MB)", "medium": "Mediano (2 - 10 MB)", "large": "Grande (10 - 20 MB)", + "huge": "Muy grande (≥20 MB)", + "sort": "Ordenar", "sizeDescription": "El tamaño se refiere al tamaño total del correo electrónico, incluidos los archivos adjuntos.", "title": "Buscar", "searching": "Buscando, por favor espera...", diff --git a/web/src/locales/fi.json b/web/src/locales/fi.json index a97fb6c..f5b5df2 100644 --- a/web/src/locales/fi.json +++ b/web/src/locales/fi.json @@ -415,7 +415,7 @@ "medium": "Keskikokoinen (2 - 10 MB)", "large": "Suuri (10 - 20 MB)", "huge": "Valtava (≥20 MB)", - "sortBy": "Lajittele", + "sort": "Lajittele", "sizeDescription": "Koko tarkoittaa sähköpostin kokonaiskokoa, liitteet mukaan lukien.", "title": "Hae", "searching": "Haetaan, odota hetki...", diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 4f6402f..e2033e7 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -414,6 +414,8 @@ "small": "Petite (<2 MB)", "medium": "Moyenne (2 - 10 MB)", "large": "Grande (10 - 20 MB)", + "huge": "Énorme (≥20 Mo)", + "sort": "Trier", "sizeDescription": "La taille correspond à la taille totale de l’e-mail, pièces jointes incluses.", "title": "Recherche", "searching": "Recherche en cours, veuillez patienter...", diff --git a/web/src/locales/it.json b/web/src/locales/it.json index 14790bc..7c2d45e 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -414,6 +414,8 @@ "small": "Piccolo (<2 MB)", "medium": "Medio (2 - 10 MB)", "large": "Grande (10 - 20 MB)", + "huge": "Enorme (≥20 MB)", + "sort": "Ordina", "sizeDescription": "La dimensione indica la dimensione totale dell’email, inclusi gli allegati.", "title": "Cerca", "searching": "Ricerca in corso, attendere prego...", diff --git a/web/src/locales/jp.json b/web/src/locales/jp.json index 12b53a7..4c29115 100644 --- a/web/src/locales/jp.json +++ b/web/src/locales/jp.json @@ -414,6 +414,8 @@ "small": "小(2 MB 未満)", "medium": "中(2 - 10 MB)", "large": "大(10 - 20 MB)", + "huge": "巨大 (≥20 MB)", + "sort": "並べ替え", "sizeDescription": "サイズは添付ファイルを含むメール全体の容量を指します。", "title": "検索", "searching": "検索中です。お待ちください…", diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index b2614c4..21723d9 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -414,6 +414,8 @@ "small": "작음 (<2 MB)", "medium": "중간 (2 - 10 MB)", "large": "큼 (10 - 20 MB)", + "huge": "대용량 (≥20 MB)", + "sort": "정렬", "sizeDescription": "크기는 첨부 파일을 포함한 이메일 전체 크기를 의미합니다.", "title": "검색", "searching": "검색 중입니다. 잠시 기다려 주십시오...", diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index 5cd9048..150d483 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -414,6 +414,8 @@ "small": "Klein (<2 MB)", "medium": "Middelgroot (2 - 10 MB)", "large": "Groot (10 - 20 MB)", + "huge": "Zeer groot (≥20 MB)", + "sort": "Sorteren", "sizeDescription": "De grootte verwijst naar de totale e-mailgrootte, inclusief bijlagen.", "title": "Zoeken", "searching": "Bezig met zoeken, even geduld alstublieft…", diff --git a/web/src/locales/no.json b/web/src/locales/no.json index 3ec9017..e44a18c 100644 --- a/web/src/locales/no.json +++ b/web/src/locales/no.json @@ -414,6 +414,8 @@ "small": "Liten (<2 MB)", "medium": "Middels (2 - 10 MB)", "large": "Stor (10 - 20 MB)", + "huge": "Kjempestor (≥20 MB)", + "sort": "Sorter", "sizeDescription": "Størrelsen viser til e-postens totale størrelse, inkludert vedlegg.", "title": "Søk", "searching": "Søker, vennligst vent…", diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index 61d853f..b0328d8 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -414,6 +414,8 @@ "small": "Mały (<2 MB)", "medium": "Średni (2 - 10 MB)", "large": "Duży (10 - 20 MB)", + "huge": "Bardzo duży (≥20 MB)", + "sort": "Sortuj", "sizeDescription": "Rozmiar odnosi się do całkowitego rozmiaru wiadomości e-mail, łącznie z załącznikami.", "title": "Szukaj", "searching": "Wyszukiwanie, proszę czekać...", diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 375fea3..d4f10dd 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -414,6 +414,8 @@ "small": "Pequeno (<2 MB)", "medium": "Médio (2 - 10 MB)", "large": "Grande (10 - 20 MB)", + "huge": "Muito grande (≥20 MB)", + "sort": "Ordenar", "sizeDescription": "O tamanho refere-se ao tamanho total do e-mail, incluindo anexos.", "title": "Pesquisa", "searching": "Pesquisando, por favor, aguarde...", diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 815d719..a9a7448 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -414,6 +414,8 @@ "small": "Маленький (<2 МБ)", "medium": "Средний (2 - 10 МБ)", "large": "Большой (10 - 20 МБ)", + "huge": "Огромный (≥20 MB)", + "sort": "Сортировка", "sizeDescription": "Размер означает общий размер электронного письма, включая вложения.", "title": "Поиск", "searching": "Поиск, пожалуйста, подождите...", diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index e9e59e1..c7e59c7 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -414,6 +414,8 @@ "small": "Liten (<2 MB)", "medium": "Medelstor (2 - 10 MB)", "large": "Stor (10 - 20 MB)", + "huge": "Mycket stor (≥20 MB)", + "sort": "Sortera", "sizeDescription": "Storleken avser e-postens totala storlek, inklusive bilagor.", "title": "Sök", "searching": "Söker, vänligen vänta…", diff --git a/web/src/locales/zh-tw.json b/web/src/locales/zh-tw.json index 3c45ff7..1e9b9e8 100644 --- a/web/src/locales/zh-tw.json +++ b/web/src/locales/zh-tw.json @@ -414,6 +414,8 @@ "small": "小(<2 MB)", "medium": "中(2 - 10 MB)", "large": "大(10 - 20 MB)", + "huge": "極大 (≥20 MB)", + "sort": "排序", "sizeDescription": "大小指的是整封郵件的大小,包含附件。", "title": "搜尋", "searching": "正在搜尋,請稍候...", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index 899cc03..2342585 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -414,6 +414,8 @@ "small": "小(<2 MB)", "medium": "中(2 - 10 MB)", "large": "大(10 - 20 MB)", + "huge": "极大 (≥20 MB)", + "sort": "排序", "sizeDescription": "大小指的是邮件整体大小,包含附件。", "title": "搜索", "searching": "搜索中,请稍候…",