This commit is contained in:
R3D347HR4Y 2026-05-20 14:06:44 +02:00
parent 9266aa34cd
commit aad897b617
15 changed files with 2004 additions and 71 deletions

View File

@ -12,9 +12,10 @@ import { useIsXs } from "@/hooks/use-xs"
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import { useMailSplitView } from "@/hooks/use-mail-split-view"
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
import { MailToaster } from "@/components/gmail/mail-toaster"
import { useRouter, usePathname } from "next/navigation"
import { useRouter, usePathname, useSearchParams } from "next/navigation"
import { Sidebar } from "@/components/gmail/sidebar"
import { Header } from "@/components/gmail/header"
import { EmailList } from "@/components/gmail/email-list"
@ -49,6 +50,7 @@ function segmentsFromPathname(pathname: string | null): string[] | undefined {
function MailAppInner() {
const router = useRouter()
const pathname = usePathname()
const currentSearchParams = useSearchParams()
const segments = useMemo(() => segmentsFromPathname(pathname), [pathname])
const route = useMemo(() => parseMailSegments(segments), [segments])
@ -68,12 +70,15 @@ function MailAppInner() {
}, [isXs])
useEffect(() => {
pushRecentFolderVisit(mailNavVisitKey(route.folderId, route.inboxTab))
if (route.folderId !== "search") {
pushRecentFolderVisit(mailNavVisitKey(route.folderId, route.inboxTab))
}
}, [route.folderId, route.inboxTab, pushRecentFolderVisit])
const [folderUnreadCounts, setFolderUnreadCounts] = useState<
Record<string, number>
>({})
const [xsViewChrome, setXsViewChrome] = useState<MailXsViewChrome | null>(null)
const [mobileSearchOpen, setMobileSearchOpen] = useState(false)
const navigateRoute = useCallback(
(patch: Partial<MailRouteState>) => {
@ -86,9 +91,13 @@ function MailAppInner() {
page: patch.page !== undefined ? patch.page : route.page,
mailId: patch.mailId !== undefined ? patch.mailId : route.mailId,
}
router.push(buildMailPath(next), { scroll: false })
let url = buildMailPath(next)
if (next.folderId === "search" && currentSearchParams.toString()) {
url += `?${currentSearchParams.toString()}`
}
router.push(url, { scroll: false })
},
[router, route]
[router, route, currentSearchParams]
)
const handleSelectFolder = useCallback(
@ -123,6 +132,7 @@ function MailAppInner() {
isXs={false}
sidebarCollapsed={sidebarCollapsed || touchNav}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
onOpenMobileSearch={() => setMobileSearchOpen(true)}
/>
</div>
) : null}
@ -194,8 +204,16 @@ function MailAppInner() {
sidebarOpen={!sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
xsViewChrome={xsViewChrome}
onOpenSearch={() => setMobileSearchOpen(true)}
searchQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""}
onClearSearch={() => router.push("/mail/inbox")}
/>
) : null}
<MobileSearchOverlay
open={mobileSearchOpen}
onClose={() => setMobileSearchOpen(false)}
initialQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""}
/>
</div>
</SidebarNavProvider>
)

View File

@ -52,9 +52,11 @@ import {
Send,
Pencil,
CalendarClock,
CalendarX2,
X,
CheckSquare,
Inbox as InboxIcon,
User as UserIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@ -122,6 +124,8 @@ import { threadStoreId } from "@/lib/mail-settings/list-row-id"
import { resolveOpenEmailView } from "@/lib/mail-settings/resolve-open-email"
import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { useActiveAccount } from "@/lib/stores/account-store"
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import {
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
MAIL_MENU_SURFACE_CLASS,
@ -152,9 +156,18 @@ import {
import {
DEFAULT_INBOX_TAB,
INBOX_ALL_TAB,
SEARCH_FOLDER_ID,
inboxTabShowsInactiveMeta,
normalizeInboxTabSegment,
} from "@/lib/mail-url"
import { useSearchParams, useRouter } from "next/navigation"
import {
parseSearchParams,
buildSearchUrl,
DATE_RANGE_OPTIONS,
type SearchParams,
} from "@/lib/mail-search/search-params"
import { filterEmailsBySearchParams } from "@/lib/mail-search/search-engine"
import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
import { ContactHoverCard } from "./contact-hover-card"
@ -625,6 +638,42 @@ export function EmailList({
}: EmailListProps) {
const isViewMode = openMailId !== null && !splitView
const showSplitReadingPane = splitView && openMailId !== null
const isSearchMode = selectedFolder === SEARCH_FOLDER_ID
const searchRouter = useRouter()
const searchAccount = useActiveAccount()
const setAdvancedOpen = useMailSearchStore((s) => s.setAdvancedOpen)
const urlSearchParams = useSearchParams()
const searchParams = useMemo(
() => (isSearchMode ? parseSearchParams(urlSearchParams) : null),
[isSearchMode, urlSearchParams]
)
const setSearchFilter = useCallback(
(patch: Partial<SearchParams>) => {
if (!searchParams) return
searchRouter.push(buildSearchUrl({ ...searchParams, ...patch }))
},
[searchParams, searchRouter]
)
const toggleSearchFilter = useCallback(
(key: keyof SearchParams, value: string) => {
if (!searchParams) return
const next = { ...searchParams }
if (key === "has") {
const arr = [...next.has]
if (arr.includes(value)) next.has = arr.filter((v) => v !== value)
else next.has = [...arr, value]
} else if (key === "excludeChats") {
next.excludeChats = !next.excludeChats
} else {
const cur = (next as Record<string, unknown>)[key]
;(next as Record<string, unknown>)[key] = cur === value ? "" : value
}
searchRouter.push(buildSearchUrl(next))
},
[searchParams, searchRouter]
)
const { savedThreadReplyDrafts } = useComposeDrafts()
const {
@ -979,6 +1028,14 @@ export function EmailList({
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
}
if (isSearchMode && searchParams) {
return filterEmailsBySearchParams(visible, searchParams, {
starredIds: starredEmails,
importantIds: importantEmails,
})
}
let rows = visible.filter((email) =>
emailMatchesFolder(
email,
@ -1029,6 +1086,10 @@ export function EmailList({
notSpamEmailIds,
allEmails,
navMaps,
isSearchMode,
searchParams,
starredEmails,
importantEmails,
])
const displayListEmails = useMemo(() => {
@ -1075,6 +1136,7 @@ export function EmailList({
)
const mobileFolderLabel = useMemo(() => {
if (isSearchMode) return "Résultats de recherche"
const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
return selectedFolder === "inbox" && inboxTabNorm !== "primary"
? inboxCategoryTabLabel
@ -1084,6 +1146,7 @@ export function EmailList({
inboxTab,
inboxCategoryTabLabel,
sidebarNav.folderIdToLabel,
isSearchMode,
])
useEffect(() => {
@ -3226,14 +3289,173 @@ export function EmailList({
</div>
)}
{isSearchMode && searchParams && listToolbarMode && (
<div className="flex w-full shrink-0 items-center gap-1.5 overflow-x-auto border-b border-[#dadce0] bg-mail-surface px-3 py-1.5 text-xs dark:border-gray-700">
{/* De dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.from
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<UserIcon className="size-3" strokeWidth={2} />
De{searchParams.from ? ` : ${searchParams.from}` : ""}
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={MAIL_MENU_SURFACE_CLASS}>
<DropdownMenuItem onSelect={() => setSearchFilter({ from: "" })}>
N&apos;importe qui
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSearchFilter({ from: searchAccount.email })}>
De moi ({searchAccount.email})
</DropdownMenuItem>
<DropdownMenuSeparator />
{Array.from(new Set(allEmails.map((e) => e.senderEmail).filter(Boolean))).slice(0, 8).map((addr) => (
<DropdownMenuItem key={addr} onSelect={() => setSearchFilter({ from: addr! })}>
{addr}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Date dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.within
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<Clock className="size-3" strokeWidth={2} />
{searchParams.within
? DATE_RANGE_OPTIONS.find((o) => o.value === searchParams.within)?.label ?? searchParams.within
: "Indifférente"}
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={MAIL_MENU_SURFACE_CLASS}>
<DropdownMenuItem onSelect={() => setSearchFilter({ within: "" })}>
Indifférente
</DropdownMenuItem>
<DropdownMenuSeparator />
{DATE_RANGE_OPTIONS.map((opt) => (
<DropdownMenuItem key={opt.value} onSelect={() => setSearchFilter({ within: opt.value })}>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Contient une pièce jointe */}
<button
type="button"
onClick={() => toggleSearchFilter("has", "attachment")}
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.has.includes("attachment")
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<Paperclip className="size-3" strokeWidth={2} />
Pièces jointes
</button>
{/* Exclure les mises à jour d'agenda */}
<button
type="button"
onClick={() => toggleSearchFilter("excludeChats", "true")}
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.excludeChats
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<CalendarX2 className="size-3" strokeWidth={2} />
Exclure les mises à jour d&apos;agenda
</button>
{/* À dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.to
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<Send className="size-3" strokeWidth={2} />
À{searchParams.to ? ` : ${searchParams.to}` : ""}
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={MAIL_MENU_SURFACE_CLASS}>
<DropdownMenuItem onSelect={() => setSearchFilter({ to: "" })}>
N&apos;importe qui
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSearchFilter({ to: searchAccount.email })}>
À moi ({searchAccount.email})
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Non lu */}
<button
type="button"
onClick={() => {
if (!searchParams) return
const next = { ...searchParams }
if (next.q.includes("is:unread")) {
next.q = next.q.replace(/\s*is:unread\s*/g, "").trim()
} else {
next.q = (next.q + " is:unread").trim()
}
searchRouter.push(buildSearchUrl(next))
}}
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.q.includes("is:unread")
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<MailOpen className="size-3" strokeWidth={2} />
Non lu
</button>
{/* Recherche avancée */}
<button
type="button"
onClick={() => setAdvancedOpen(true)}
className="ml-auto shrink-0 px-2 py-1 text-xs font-medium text-[#1a73e8] hover:text-[#1765cc] dark:text-blue-400"
>
Recherche avancée
</button>
</div>
)}
<div className={cn("relative flex min-h-0 flex-1 flex-col")}>
<div
ref={listViewportRef}
className={cn(
className={cn("max-sm:pb-16",
!splitView && isViewMode && openEmail
? "relative flex min-h-0 flex-1 flex-col overflow-hidden"
: mainScrollClass,
"relative min-h-0 flex-1 overscroll-y-none max-sm:pb-16"
"relative min-h-0 flex-1 overscroll-y-none"
)}
>
{listToolbarMode && (
@ -3254,7 +3476,7 @@ export function EmailList({
<div
ref={pullContentRef}
className={cn(
!splitView && isViewMode && openEmail && "relative flex min-h-0 flex-1 flex-col",
!splitView && isViewMode && openEmail && "relative flex min-h-0 flex-1 flex-col ",
listToolbarMode && "max-sm:[transform:translateZ(0)]"
)}
>
@ -3346,6 +3568,30 @@ export function EmailList({
<div className="flex min-h-[220px] flex-col items-center justify-center px-4 py-12 text-center">
<p className="text-sm text-[#5f6368]">Aucun message planifié.</p>
</div>
) : isSearchMode && searchParams ? (
<Empty className="min-h-[240px] flex-1 border-0 bg-mail-surface py-10 shadow-none">
<EmptyHeader className="max-w-md">
<EmptyMedia
variant="icon"
className="mb-1 border-0 bg-[#f1f3f4] text-[#5f6368] [&_svg]:size-6"
>
<Search className="size-6" strokeWidth={1.5} aria-hidden />
</EmptyMedia>
<EmptyTitle className="text-[15px] font-medium text-[#3c4043]">
Aucun résultat
</EmptyTitle>
<EmptyDescription className="text-[13px] text-[#5f6368]">
Pas de résultats pour{" "}
<span className="font-medium text-[#3c4043]">
{searchParams.q || searchParams.hasWords || searchParams.from || searchParams.subject || "votre recherche"}
</span>
{(searchParams.has.length > 0 || searchParams.within || searchParams.from || searchParams.to || searchParams.subject) ? (
<> avec les filtres choisis</>
) : null}
.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<Empty className="min-h-[240px] flex-1 border-0 bg-mail-surface py-10 shadow-none">
<EmptyHeader className="max-w-md">

View File

@ -9,11 +9,10 @@ import { cn } from "@/lib/utils"
interface HeaderProps {
onToggleSidebar: () => void
/** Match `<main>` horizontal offset (same width as sidebar rail spacer). */
sidebarCollapsed: boolean
isXs?: boolean
/** Split pane shows search over the list column only. */
hideSearch?: boolean
onOpenMobileSearch?: () => void
}
export function Header({
@ -21,6 +20,7 @@ export function Header({
sidebarCollapsed,
isXs = false,
hideSearch = false,
onOpenMobileSearch,
}: HeaderProps) {
return (
<header className="flex h-16 w-full min-w-0 items-center gap-0 bg-app-canvas pl-0 pr-4 sm:gap-2">
@ -51,11 +51,12 @@ export function Header({
size="icon"
className="size-12 shrink-0 rounded-full border border-[#d3e3fd] bg-[#eaf1fb] text-gray-500 hover:bg-[#dfe9f7] sm:hidden"
aria-label="Rechercher dans les messages"
onClick={onOpenMobileSearch}
>
<Search className="size-5 shrink-0 ml-0.5" />
</Button>
{!hideSearch ? (
<div className="hidden min-w-0 flex-1 max-w-3xl sm:flex">
<div className="hidden min-w-0 flex-1 max-w-3xl overflow-visible sm:flex">
<MailSearchBar />
</div>
) : (

View File

@ -1,43 +1,704 @@
"use client"
import { Search, SlidersHorizontal } from "lucide-react"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react"
import { useRouter, useSearchParams, usePathname } from "next/navigation"
import {
Search,
SlidersHorizontal,
X,
Paperclip,
Clock,
User,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { emails } from "@/lib/email-data"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useActiveAccount } from "@/lib/stores/account-store"
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import {
matchContacts,
matchEmails,
bestCompletion,
type SearchSuggestion,
} from "@/lib/mail-search/search-engine"
import {
buildSearchUrl,
parseSearchParams,
EMPTY_SEARCH_PARAMS,
DATE_RANGE_OPTIONS,
SEARCH_IN_OPTIONS,
type SearchParams,
} from "@/lib/mail-search/search-params"
import { avatarColor, senderInitial } from "@/lib/sender-display"
interface MailSearchBarProps {
className?: string
/** Split-pane column: balanced icon inset inside the pill. */
compact?: boolean
}
export function MailSearchBar({ className, compact = false }: MailSearchBarProps) {
// ─── Advanced Search Panel ───────────────────────────────────────────────────
function AdvancedSearchPanel({
onClose,
initialQuery,
currentParams,
}: {
onClose: () => void
initialQuery: string
currentParams: SearchParams | null
}) {
const router = useRouter()
const [from, setFrom] = useState(currentParams?.from ?? "")
const [to, setTo] = useState(currentParams?.to ?? "")
const [subject, setSubject] = useState(currentParams?.subject ?? "")
const [hasWords, setHasWords] = useState(
currentParams?.hasWords || currentParams?.q || initialQuery
)
const [doesNotHave, setDoesNotHave] = useState(currentParams?.doesNotHave ?? "")
const [sizeVal, setSizeVal] = useState(currentParams?.size ?? "")
const [sizeOp, setSizeOp] = useState<"gt" | "lt">(currentParams?.sizeOp ?? "gt")
const [sizeUnit, setSizeUnit] = useState<"Mo" | "Ko">(currentParams?.sizeUnit ?? "Mo")
const [within, setWithin] = useState(currentParams?.within ?? "")
const [dateAfter, setDateAfter] = useState(currentParams?.after ?? "")
const [searchIn, setSearchIn] = useState(currentParams?.in ?? "all")
const [hasAttachment, setHasAttachment] = useState(
currentParams?.has?.includes("attachment") ?? false
)
const [excludeChats, setExcludeChats] = useState(currentParams?.excludeChats ?? false)
const handleSubmit = () => {
const params: Partial<SearchParams> = {
...EMPTY_SEARCH_PARAMS,
q: "",
from,
to,
subject,
hasWords,
doesNotHave,
size: sizeVal,
sizeOp,
sizeUnit,
within,
after: dateAfter,
in: searchIn,
has: hasAttachment ? ["attachment"] : [],
excludeChats,
}
router.push(buildSearchUrl(params))
onClose()
}
return (
<div className={cn("relative flex w-full min-w-0 items-center", className)}>
<div
className={cn(
"pointer-events-none absolute flex items-center text-gray-500",
compact ? "left-4" : "left-3.5"
)}
>
<Search className="size-5 shrink-0" />
<div
className={cn(
"absolute left-0 top-full z-50 mt-1 max-h-[80vh] overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900",
"sm:min-w-[34rem] sm:max-w-[min(42rem,calc(100vw-5rem))]",
"md:min-w-[38rem]",
"lg:right-0 lg:min-w-0 lg:max-w-none"
)}
>
<div className="space-y-3 p-4">
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
De
</Label>
<Input
value={from}
onChange={(e) => setFrom(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
autoFocus
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
À
</Label>
<Input
value={to}
onChange={(e) => setTo(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Objet
</Label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Contient les mots
</Label>
<Input
value={hasWords}
onChange={(e) => setHasWords(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Ne contient pas
</Label>
<Input
value={doesNotHave}
onChange={(e) => setDoesNotHave(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Taille
</Label>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gt">supérieure à</SelectItem>
<SelectItem value="lt">inférieure à</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
value={sizeVal}
onChange={(e) => setSizeVal(e.target.value)}
className="h-8 w-20 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
<Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mo">Mo</SelectItem>
<SelectItem value="Ko">Ko</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Plage de dates
</Label>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Select value={within} onValueChange={setWithin}>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue placeholder="Sélectionner" />
</SelectTrigger>
<SelectContent>
{DATE_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
value={dateAfter}
onChange={(e) => setDateAfter(e.target.value)}
className="h-8 min-w-0 flex-1 rounded border border-gray-300 bg-transparent px-2 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
</div>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Rechercher
</Label>
<Select value={searchIn} onValueChange={setSearchIn}>
<SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEARCH_IN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-6 pt-1">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Checkbox
checked={hasAttachment}
onCheckedChange={(v) => setHasAttachment(v === true)}
/>
Contenant une pièce jointe
</label>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Checkbox
checked={excludeChats}
onCheckedChange={(v) => setExcludeChats(v === true)}
/>
Ne pas inclure les chats
</label>
</div>
<div className="flex items-center justify-end gap-3 border-t border-gray-100 pt-3 dark:border-gray-800">
<Button variant="ghost" className="text-sm text-blue-600" disabled>
Créer un filtre
</Button>
<Button
className="bg-[#1a73e8] text-sm text-white hover:bg-[#1765cc]"
onClick={handleSubmit}
>
Rechercher
</Button>
</div>
</div>
<Input
type="text"
placeholder="Rechercher dans les messages"
className={cn(
"h-12 w-full rounded-full border-0 bg-muted text-sm focus-visible:bg-mail-surface focus-visible:ring-1 focus-visible:ring-ring",
compact ? "pl-11 pr-11" : "pl-11 pr-12"
)}
/>
<Button
variant="ghost"
size="icon"
className={cn("absolute text-gray-600", compact ? "right-3" : "right-2")}
aria-label="Filtres de recherche"
>
<SlidersHorizontal className="h-5 w-5" />
</Button>
</div>
)
}
// ─── Main Search Bar ─────────────────────────────────────────────────────────
export function MailSearchBar({
className,
compact = false,
}: MailSearchBarProps) {
const router = useRouter()
const pathname = usePathname()
const isOnSearchPage = pathname?.includes("/mail/search") ?? false
const urlSearchParams = useSearchParams()
const currentSearchParams = useMemo(
() => parseSearchParams(urlSearchParams),
[urlSearchParams]
)
const account = useActiveAccount()
const contacts = useContactsStore((s) => s.contacts)
const inputValue = useMailSearchStore((s) => s.inputValue)
const dropdownOpen = useMailSearchStore((s) => s.dropdownOpen)
const selectedIndex = useMailSearchStore((s) => s.selectedIndex)
const advancedOpen = useMailSearchStore((s) => s.advancedOpen)
const chipAttachment = useMailSearchStore((s) => s.chipAttachment)
const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
const {
setInputValue,
setDropdownOpen,
setSelectedIndex,
setAdvancedOpen,
toggleChipAttachment,
toggleChipLast7Days,
toggleChipFromMe,
reset,
} = useMailSearchStore.getState()
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [focused, setFocused] = useState(false)
useEffect(() => {
const q = currentSearchParams?.q ?? ""
if (q && !inputValue) {
setInputValue(q)
}
}, [currentSearchParams?.q])
const suggestions = useMemo<SearchSuggestion[]>(() => {
if (!inputValue.trim()) return []
const contactHits = matchContacts(inputValue, contacts, 5)
const emailHits = matchEmails(inputValue, emails, 5)
const seen = new Set(contactHits.map((c) => c.email))
const unique = emailHits.filter((e) => !seen.has(e.email))
return [...contactHits, ...unique]
}, [inputValue, contacts])
const ghostText = useMemo(
() => bestCompletion(inputValue, suggestions),
[inputValue, suggestions]
)
const totalItems = suggestions.length + 1
const submitSearch = useCallback(
(overrideQuery?: string) => {
const q = overrideQuery ?? inputValue
if (!q.trim() && !chipAttachment && !chipLast7Days && !chipFromMe) return
const params: Partial<SearchParams> = { q: q.trim() }
if (chipAttachment) params.has = ["attachment"]
if (chipLast7Days) params.within = "1w"
if (chipFromMe) params.from = account.email
router.push(buildSearchUrl(params))
setDropdownOpen(false)
inputRef.current?.blur()
},
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router]
)
const selectSuggestion = useCallback(
(s: SearchSuggestion) => {
const params: Partial<SearchParams> = { q: s.email }
if (chipAttachment) params.has = ["attachment"]
if (chipLast7Days) params.within = "1w"
if (chipFromMe) params.from = account.email
router.push(buildSearchUrl(params))
setInputValue(s.email)
setDropdownOpen(false)
inputRef.current?.blur()
},
[chipAttachment, chipLast7Days, chipFromMe, account.email, router]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (!dropdownOpen && e.key !== "Enter") return
switch (e.key) {
case "ArrowDown":
e.preventDefault()
setSelectedIndex(
selectedIndex < totalItems - 1 ? selectedIndex + 1 : 0
)
break
case "ArrowUp":
e.preventDefault()
setSelectedIndex(
selectedIndex > 0 ? selectedIndex - 1 : totalItems - 1
)
break
case "Enter":
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
selectSuggestion(suggestions[selectedIndex]!)
} else {
submitSearch()
}
break
case "Tab":
if (ghostText) {
e.preventDefault()
setInputValue(inputValue + ghostText)
}
break
case "Escape":
e.preventDefault()
setDropdownOpen(false)
setAdvancedOpen(false)
inputRef.current?.blur()
break
}
},
[
dropdownOpen,
selectedIndex,
totalItems,
suggestions,
ghostText,
inputValue,
submitSearch,
selectSuggestion,
]
)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement | null
if (!target) return
if (containerRef.current?.contains(target)) return
if (target.closest("[data-radix-popper-content-wrapper]")) return
setDropdownOpen(false)
setAdvancedOpen(false)
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const showDropdown = dropdownOpen && inputValue.trim().length > 0 && focused
return (
<div
ref={containerRef}
className={cn("relative flex w-full min-w-0 flex-col overflow-visible", className)}
>
{/* Input row */}
<div className="relative flex w-full min-w-0 items-center">
<div
className={cn(
"pointer-events-none absolute flex items-center text-gray-500",
compact ? "left-4" : "left-3.5"
)}
>
<Search className="size-5 shrink-0" />
</div>
{/* Ghost text overlay */}
{ghostText && focused && (
<div
className={cn(
"pointer-events-none absolute flex items-center text-sm text-gray-400",
compact ? "left-[44px]" : "left-[44px]"
)}
aria-hidden
>
<span className="invisible">{inputValue}</span>
<span>{ghostText}</span>
</div>
)}
<input
ref={inputRef}
type="text"
placeholder="Rechercher dans les messages"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onFocus={() => {
setFocused(true)
if (inputValue.trim()) setDropdownOpen(true)
}}
onBlur={() => setFocused(false)}
onKeyDown={handleKeyDown}
className={cn(
"h-12 w-full rounded-full border-0 bg-muted text-sm outline-none placeholder:text-gray-500",
focused || advancedOpen
? "bg-white shadow-md ring-1 ring-gray-300 dark:bg-gray-900 dark:ring-gray-600"
: "",
compact ? "pl-11 pr-20" : "pl-11 pr-20"
)}
role="combobox"
aria-expanded={showDropdown}
aria-autocomplete="list"
aria-controls="search-suggestions"
autoComplete="off"
/>
{/* Clear button */}
{inputValue && (
<Button
variant="ghost"
size="icon"
className="absolute right-10 text-gray-600"
onClick={() => {
reset()
if (isOnSearchPage) {
router.push("/mail/inbox")
} else {
inputRef.current?.focus()
}
}}
aria-label="Effacer la recherche"
>
<X className="h-5 w-5" />
</Button>
)}
{/* Advanced settings button */}
<Button
variant="ghost"
size="icon"
className={cn(
"absolute text-gray-600",
compact ? "right-3" : "right-2"
)}
aria-label="Filtres de recherche"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const current = useMailSearchStore.getState().advancedOpen
setAdvancedOpen(!current)
}}
>
<SlidersHorizontal className="h-5 w-5" />
</Button>
</div>
{/* Dropdown */}
{showDropdown && !advancedOpen && (
<div
id="search-suggestions"
role="listbox"
className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
>
{/* Filter chips */}
<div className="flex items-center gap-2 overflow-x-auto border-b border-gray-100 px-4 py-2 whitespace-nowrap dark:border-gray-800">
<button
type="button"
onClick={(e) => {
e.preventDefault()
toggleChipAttachment()
}}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors",
chipAttachment
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-gray-200 text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
)}
>
<Paperclip className="size-3.5" />
Contient une pièce jointe
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault()
toggleChipLast7Days()
}}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors",
chipLast7Days
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-gray-200 text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
)}
>
<Clock className="size-3.5" />
7 derniers jours
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault()
toggleChipFromMe()
}}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors",
chipFromMe
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-gray-200 text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
)}
>
<User className="size-3.5" />
De moi
</button>
</div>
{/* Suggestions */}
{suggestions.map((s, i) => {
const isSelected = i === selectedIndex
if (s.kind === "contact") {
const initial = senderInitial(s.displayName)
const color = avatarColor(s.displayName)
return (
<button
key={`c-${s.contact.id}-${s.email}`}
type="button"
role="option"
aria-selected={isSelected}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
isSelected
? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectSuggestion(s)}
onMouseEnter={() => setSelectedIndex(i)}
>
<div
className="flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
style={{ backgroundColor: color }}
>
{initial}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-gray-900 dark:text-gray-100">
{s.displayName}
</div>
<div className="truncate text-xs text-gray-500">
{s.email}
</div>
</div>
</button>
)
}
return (
<button
key={`e-${s.email}`}
type="button"
role="option"
aria-selected={isSelected}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
isSelected
? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectSuggestion(s)}
onMouseEnter={() => setSelectedIndex(i)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-gray-200 text-gray-500 dark:bg-gray-700">
<User className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-gray-700 dark:text-gray-300">
{s.email}
</div>
</div>
</button>
)
})}
{/* "All results" row */}
<button
type="button"
role="option"
aria-selected={selectedIndex === suggestions.length}
className={cn(
"flex w-full items-center gap-3 border-t border-gray-100 px-4 py-2.5 text-left text-sm transition-colors dark:border-gray-800",
selectedIndex === suggestions.length
? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => submitSearch()}
onMouseEnter={() => setSelectedIndex(suggestions.length)}
>
<div className="flex size-8 shrink-0 items-center justify-center">
<Search className="size-5 text-gray-400" />
</div>
<span className="text-gray-600 dark:text-gray-400">
Tous les résultats de recherche pour &laquo;&nbsp;
<span className="font-medium text-gray-900 dark:text-gray-100">
{inputValue}
</span>
&nbsp;&raquo;
</span>
<span className="ml-auto text-xs text-gray-400">
Appuyer sur ENTRÉE
</span>
</button>
</div>
)}
{/* Advanced search panel */}
{advancedOpen && (
<AdvancedSearchPanel
onClose={() => setAdvancedOpen(false)}
initialQuery={inputValue}
currentParams={currentSearchParams}
/>
)}
</div>
)
}

View File

@ -1,11 +1,10 @@
"use client"
import { useState, useRef, useEffect, useCallback } from "react"
import {
Menu,
Search,
X,
Pencil,
Search,
Archive,
FolderInput,
Reply,
@ -27,6 +26,9 @@ interface MobileBottomBarProps {
onToggleSidebar: () => void
/** Lecture message xs : barre dactions à la place du menu / recherche. */
xsViewChrome?: MailXsViewChrome | null
onOpenSearch?: () => void
searchQuery?: string
onClearSearch?: () => void
}
const ROUNDED_BAR_BTN =
@ -36,31 +38,19 @@ export function MobileBottomBar({
sidebarOpen,
onToggleSidebar,
xsViewChrome = null,
onOpenSearch,
searchQuery,
onClearSearch,
}: MobileBottomBarProps) {
const [searchValue, setSearchValue] = useState("")
const inputRef = useRef<HTMLInputElement>(null)
const { openCompose } = useComposeActions()
const inMailView = Boolean(xsViewChrome)
const hasSearch = searchValue.length > 0
const handleClear = useCallback(() => {
setSearchValue("")
inputRef.current?.focus()
}, [])
useEffect(() => {
if (sidebarOpen) {
inputRef.current?.blur()
}
}, [sidebarOpen])
return (
<div className="fixed inset-x-0 bottom-0 z-50 flex flex-col items-center pb-[env(safe-area-inset-bottom)] sm:hidden">
<div className={cn(
"pointer-events-none absolute inset-0 bg-gradient-to-t to-transparent",
inMailView
? "from-black/90 via-black/50"
? "dark:from-black/90 dark:via-black/50 from-mail-surface/90 via-mail-surface/50"
: "from-mail-surface/95 via-mail-surface/70 dark:from-background/95 dark:via-background/70"
)} />
@ -131,19 +121,22 @@ export function MobileBottomBar({
</Button>
{!sidebarOpen && (
<div className="relative flex min-w-0 flex-1 items-center">
<button
type="button"
className="relative flex min-w-0 flex-1 items-center"
onClick={onOpenSearch}
>
<div className="pointer-events-none absolute left-3 z-10 flex items-center text-gray-500">
<Search className="size-5" />
</div>
<input
ref={inputRef}
type="text"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder="Rechercher"
className="h-11 w-full rounded-full border border-gray-200 bg-white/80 pl-10 pr-4 text-sm shadow-md backdrop-blur outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-blue-500/40"
/>
</div>
<div
className="flex h-11 w-full items-center rounded-full border border-gray-200 bg-white/80 pl-10 pr-4 text-left text-sm shadow-md backdrop-blur"
>
<span className={searchQuery ? "truncate text-gray-900 dark:text-gray-100" : "text-gray-400"}>
{searchQuery || "Rechercher"}
</span>
</div>
</button>
)}
</>
)}
@ -153,10 +146,10 @@ export function MobileBottomBar({
variant="ghost"
size="icon"
className={cn(ROUNDED_BAR_BTN, inMailView && "ml-auto")}
onClick={inMailView || !hasSearch ? openCompose : handleClear}
aria-label={!inMailView && hasSearch ? "Effacer la recherche" : "Nouveau message"}
onClick={searchQuery ? onClearSearch : openCompose}
aria-label={searchQuery ? "Quitter la recherche" : "Nouveau message"}
>
{!inMailView && hasSearch ? (
{searchQuery ? (
<X className="size-5" />
) : (
<Pencil className="size-5" />

View File

@ -0,0 +1,522 @@
"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
Search,
X,
Paperclip,
Clock,
User,
SlidersHorizontal,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { emails } from "@/lib/email-data"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useActiveAccount } from "@/lib/stores/account-store"
import {
matchContacts,
matchEmails,
bestCompletion,
type SearchSuggestion,
} from "@/lib/mail-search/search-engine"
import {
buildSearchUrl,
EMPTY_SEARCH_PARAMS,
DATE_RANGE_OPTIONS,
SEARCH_IN_OPTIONS,
type SearchParams,
} from "@/lib/mail-search/search-params"
import { avatarColor, senderInitial } from "@/lib/sender-display"
interface MobileSearchOverlayProps {
open: boolean
onClose: () => void
initialQuery?: string
}
export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: MobileSearchOverlayProps) {
const router = useRouter()
const account = useActiveAccount()
const contacts = useContactsStore((s) => s.contacts)
const [inputValue, setInputValue] = useState(initialQuery)
const [selectedIndex, setSelectedIndex] = useState(-1)
const [chipAttachment, setChipAttachment] = useState(false)
const [chipLast7Days, setChipLast7Days] = useState(false)
const [chipFromMe, setChipFromMe] = useState(false)
const [advancedMode, setAdvancedMode] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setInputValue(initialQuery)
setAdvancedMode(false)
setTimeout(() => inputRef.current?.focus(), 50)
} else {
setSelectedIndex(-1)
setChipAttachment(false)
setChipLast7Days(false)
setChipFromMe(false)
setAdvancedMode(false)
}
}, [open, initialQuery])
const suggestions = useMemo<SearchSuggestion[]>(() => {
if (!inputValue.trim()) return []
const contactHits = matchContacts(inputValue, contacts, 4)
const emailHits = matchEmails(inputValue, emails, 4)
const seen = new Set(contactHits.map((c) => c.email))
const unique = emailHits.filter((e) => !seen.has(e.email))
return [...contactHits, ...unique]
}, [inputValue, contacts])
const ghostText = useMemo(
() => bestCompletion(inputValue, suggestions),
[inputValue, suggestions]
)
const totalItems = suggestions.length + 1
const submitSearch = useCallback(
(overrideQuery?: string) => {
const q = overrideQuery ?? inputValue
if (!q.trim() && !chipAttachment && !chipLast7Days && !chipFromMe) return
const params: Partial<SearchParams> = { q: q.trim() }
if (chipAttachment) params.has = ["attachment"]
if (chipLast7Days) params.within = "1w"
if (chipFromMe) params.from = account.email
router.push(buildSearchUrl(params))
onClose()
},
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
)
const selectSuggestion = useCallback(
(s: SearchSuggestion) => {
const params: Partial<SearchParams> = { q: s.email }
if (chipAttachment) params.has = ["attachment"]
if (chipLast7Days) params.within = "1w"
if (chipFromMe) params.from = account.email
router.push(buildSearchUrl(params))
onClose()
},
[chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault()
setSelectedIndex((i) => (i < totalItems - 1 ? i + 1 : 0))
break
case "ArrowUp":
e.preventDefault()
setSelectedIndex((i) => (i > 0 ? i - 1 : totalItems - 1))
break
case "Enter":
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
selectSuggestion(suggestions[selectedIndex]!)
} else {
submitSearch()
}
break
case "Tab":
if (ghostText) {
e.preventDefault()
setInputValue(inputValue + ghostText)
}
break
case "Escape":
e.preventDefault()
onClose()
break
}
},
[selectedIndex, totalItems, suggestions, ghostText, inputValue, submitSearch, selectSuggestion, onClose]
)
return (
<Sheet open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<SheetContent
side="bottom"
hideClose
overlayClassName="z-[100] bg-black/40"
className={cn(
"z-[101] flex h-[100dvh] max-h-[100dvh] w-full flex-col gap-0 rounded-none border-0 bg-background p-0 shadow-xl",
"duration-300 ease-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
"pb-[env(safe-area-inset-bottom)]"
)}
>
<SheetTitle className="sr-only">Rechercher dans les messages</SheetTitle>
{/* Header */}
<div className="flex shrink-0 items-center gap-2 border-b border-gray-200 px-2 py-2 dark:border-gray-800">
<Button
variant="ghost"
size="icon"
className="size-10 shrink-0 text-gray-600"
onClick={() => { if (advancedMode) setAdvancedMode(false); else onClose() }}
aria-label="Retour"
>
<ArrowLeft className="size-5" />
</Button>
<div className="relative flex min-w-0 flex-1 items-center">
{ghostText && !advancedMode && (
<div className="pointer-events-none absolute left-0 flex items-center text-sm text-gray-400" aria-hidden>
<span className="invisible">{inputValue}</span>
<span>{ghostText}</span>
</div>
)}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
setSelectedIndex(-1)
if (advancedMode) setAdvancedMode(false)
}}
onKeyDown={handleKeyDown}
placeholder="Rechercher dans les messages"
className="h-10 w-full bg-transparent text-sm outline-none placeholder:text-gray-400"
autoComplete="off"
/>
</div>
{inputValue && !advancedMode && (
<Button
variant="ghost"
size="icon"
className="size-10 shrink-0 text-gray-600"
onClick={() => {
setInputValue("")
inputRef.current?.focus()
}}
aria-label="Effacer"
>
<X className="size-5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-10 shrink-0 text-gray-600"
onClick={() => setAdvancedMode(!advancedMode)}
aria-label="Recherche avancée"
>
<SlidersHorizontal className="size-5" />
</Button>
</div>
{advancedMode ? (
<MobileAdvancedSearch
initialQuery={inputValue}
onSubmit={(url) => { router.push(url); onClose() }}
/>
) : (
<>
{/* Filter chips */}
<div className="flex items-center gap-2 overflow-x-auto border-b border-gray-100 px-4 py-2 dark:border-gray-800">
<button
type="button"
onClick={() => setChipAttachment(!chipAttachment)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
chipAttachment
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-gray-200 text-gray-600 dark:border-gray-700 dark:text-gray-400"
)}
>
<Paperclip className="size-3.5" />
Contient une pièce jointe
</button>
<button
type="button"
onClick={() => setChipLast7Days(!chipLast7Days)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
chipLast7Days
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-gray-200 text-gray-600 dark:border-gray-700 dark:text-gray-400"
)}
>
<Clock className="size-3.5" />
7 derniers jours
</button>
<button
type="button"
onClick={() => setChipFromMe(!chipFromMe)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
chipFromMe
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-gray-200 text-gray-600 dark:border-gray-700 dark:text-gray-400"
)}
>
<User className="size-3.5" />
De moi
</button>
</div>
{/* Suggestions */}
<div className="min-h-0 flex-1 overflow-y-auto">
{inputValue.trim() && (
<>
{suggestions.map((s, i) => {
const isSelected = i === selectedIndex
if (s.kind === "contact") {
const initial = senderInitial(s.displayName)
const color = avatarColor(s.displayName)
return (
<button
key={`c-${s.contact.id}-${s.email}`}
type="button"
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-gray-100 dark:active:bg-gray-800",
isSelected && "bg-gray-100 dark:bg-gray-800"
)}
onClick={() => selectSuggestion(s)}
>
<div
className="flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
style={{ backgroundColor: color }}
>
{initial}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-gray-900 dark:text-gray-100">
{s.displayName}
</div>
<div className="truncate text-xs text-gray-500">
{s.email}
</div>
</div>
</button>
)
}
return (
<button
key={`e-${s.email}`}
type="button"
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-gray-100 dark:active:bg-gray-800",
isSelected && "bg-gray-100 dark:bg-gray-800"
)}
onClick={() => selectSuggestion(s)}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-gray-200 text-gray-500 dark:bg-gray-700">
<User className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-gray-700 dark:text-gray-300">
{s.email}
</div>
</div>
</button>
)
})}
{/* All results row */}
<button
type="button"
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-gray-100 dark:active:bg-gray-800",
selectedIndex === suggestions.length && "bg-gray-100 dark:bg-gray-800"
)}
onClick={() => submitSearch()}
>
<div className="flex size-9 shrink-0 items-center justify-center">
<Search className="size-5 text-gray-400" />
</div>
<span className="text-gray-600 dark:text-gray-400">
Tous les résultats pour «&nbsp;
<span className="font-medium text-gray-900 dark:text-gray-100">
{inputValue}
</span>
&nbsp;»
</span>
</button>
</>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
)
}
// ─── Mobile Advanced Search ──────────────────────────────────────────────────
function MobileAdvancedSearch({
initialQuery,
onSubmit,
}: {
initialQuery: string
onSubmit: (url: string) => void
}) {
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [subject, setSubject] = useState("")
const [hasWords, setHasWords] = useState(initialQuery)
const [doesNotHave, setDoesNotHave] = useState("")
const [sizeVal, setSizeVal] = useState("")
const [sizeOp, setSizeOp] = useState<"gt" | "lt">("gt")
const [sizeUnit, setSizeUnit] = useState<"Mo" | "Ko">("Mo")
const [within, setWithin] = useState("")
const [searchIn, setSearchIn] = useState("all")
const [hasAttachment, setHasAttachment] = useState(false)
const [excludeChats, setExcludeChats] = useState(false)
const handleSubmit = () => {
const params: Partial<SearchParams> = {
...EMPTY_SEARCH_PARAMS,
q: "",
from,
to,
subject,
hasWords,
doesNotHave,
size: sizeVal,
sizeOp,
sizeUnit,
within,
in: searchIn,
has: hasAttachment ? ["attachment"] : [],
excludeChats,
}
onSubmit(buildSearchUrl(params))
}
return (
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs text-gray-500">De</Label>
<Input value={from} onChange={(e) => setFrom(e.target.value)} className="h-9 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">À</Label>
<Input value={to} onChange={(e) => setTo(e.target.value)} className="h-9 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">Objet</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-9 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">Contient les mots</Label>
<Input value={hasWords} onChange={(e) => setHasWords(e.target.value)} className="h-9 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">Ne contient pas</Label>
<Input value={doesNotHave} onChange={(e) => setDoesNotHave(e.target.value)} className="h-9 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">Taille</Label>
<div className="flex items-center gap-2">
<Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}>
<SelectTrigger className="h-9 flex-1 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gt">supérieure à</SelectItem>
<SelectItem value="lt">inférieure à</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
value={sizeVal}
onChange={(e) => setSizeVal(e.target.value)}
className="h-9 w-20 text-sm"
/>
<Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}>
<SelectTrigger className="h-9 w-20 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mo">Mo</SelectItem>
<SelectItem value="Ko">Ko</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">Plage de dates</Label>
<Select value={within} onValueChange={setWithin}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Sélectionner" />
</SelectTrigger>
<SelectContent>
{DATE_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">Rechercher dans</Label>
<Select value={searchIn} onValueChange={setSearchIn}>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEARCH_IN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3 pt-1">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Checkbox
checked={hasAttachment}
onCheckedChange={(v) => setHasAttachment(v === true)}
/>
Contenant une pièce jointe
</label>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Checkbox
checked={excludeChats}
onCheckedChange={(v) => setExcludeChats(v === true)}
/>
Ne pas inclure les chats
</label>
</div>
<Button
className="w-full bg-[#1a73e8] text-sm text-white hover:bg-[#1765cc]"
onClick={handleSubmit}
>
Rechercher
</Button>
</div>
</div>
)
}

View File

@ -884,7 +884,7 @@ export function Sidebar({
}, [folderIdToLabel])
useEffect(() => {
if (!validNavFolderIds.has(selectedFolder)) {
if (selectedFolder !== "search" && !validNavFolderIds.has(selectedFolder)) {
onSelectFolder("inbox")
}
}, [validNavFolderIds, selectedFolder, onSelectFolder])

View File

@ -84,7 +84,7 @@ export const MAIL_TOOLBAR_ICON_BTN = cn(
export const MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS = "dark:!text-white"
export const MAIL_PREVIEW_SCROLL_CLASS =
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain outline-none " +
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain outline-none max-sm:pb-16 " +
"[scrollbar-color:color-mix(in_srgb,var(--muted-foreground)_55%,transparent)_transparent] [scrollbar-width:auto] " +
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground/45"

View File

@ -9,6 +9,7 @@ import {
ShieldAlert,
Trash2,
User,
Search,
} from "lucide-react"
import {
folderTreeNavIconNameClosed,
@ -30,6 +31,7 @@ const SYSTEM_ICONS: Record<string, LucideIcon> = {
scheduled: ClockArrowUp,
spam: ShieldAlert,
trash: Trash2,
search: Search,
}
export type MailNavIcon =

View File

@ -0,0 +1,296 @@
import type { Email } from "@/lib/email-data"
import type { FullContact } from "@/lib/contacts/types"
import type { SearchParams } from "./search-params"
// ---------------------------------------------------------------------------
// Suggestion types
// ---------------------------------------------------------------------------
export type ContactSuggestion = {
kind: "contact"
contact: FullContact
email: string
displayName: string
/** Score 0-1 (higher = better match). */
score: number
}
export type EmailSuggestion = {
kind: "email"
email: string
displayName: string
score: number
}
export type SearchSuggestion = ContactSuggestion | EmailSuggestion
// ---------------------------------------------------------------------------
// Prefix matching helpers
// ---------------------------------------------------------------------------
function normalize(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
}
function prefixScore(haystack: string, needle: string): number {
const h = normalize(haystack)
const n = normalize(needle)
if (!n) return 0
if (h === n) return 1
if (h.startsWith(n)) return 0.9 + 0.1 * (n.length / h.length)
const idx = h.indexOf(n)
if (idx > 0) return 0.5 + 0.4 * (n.length / h.length)
return 0
}
// ---------------------------------------------------------------------------
// matchContacts
// ---------------------------------------------------------------------------
export function matchContacts(
query: string,
contacts: FullContact[],
limit = 5
): ContactSuggestion[] {
if (!query.trim()) return []
const results: ContactSuggestion[] = []
for (const c of contacts) {
const fullName = `${c.firstName} ${c.lastName}`.trim()
let bestScore = 0
let bestEmail = c.emails[0]?.value ?? ""
bestScore = Math.max(bestScore, prefixScore(fullName, query))
bestScore = Math.max(bestScore, prefixScore(c.firstName, query))
bestScore = Math.max(bestScore, prefixScore(c.lastName, query))
for (const e of c.emails) {
const s = prefixScore(e.value, query)
if (s > bestScore) {
bestScore = s
bestEmail = e.value
}
}
if (bestScore > 0) {
results.push({
kind: "contact",
contact: c,
email: bestEmail,
displayName: fullName,
score: bestScore,
})
}
}
results.sort((a, b) => b.score - a.score)
return results.slice(0, limit)
}
// ---------------------------------------------------------------------------
// matchEmails (unique sender emails from email data)
// ---------------------------------------------------------------------------
export function matchEmails(
query: string,
emails: Email[],
limit = 5
): EmailSuggestion[] {
if (!query.trim()) return []
const seen = new Map<string, { name: string; score: number }>()
for (const e of emails) {
if (!e.senderEmail) continue
const addr = e.senderEmail.toLowerCase()
const name = e.sender
const s1 = prefixScore(addr, query)
const s2 = prefixScore(name, query)
const s = Math.max(s1, s2)
if (s > 0) {
const existing = seen.get(addr)
if (!existing || s > existing.score) {
seen.set(addr, { name, score: s })
}
}
}
const results: EmailSuggestion[] = []
for (const [addr, { name, score }] of seen) {
results.push({ kind: "email", email: addr, displayName: name, score })
}
results.sort((a, b) => b.score - a.score)
return results.slice(0, limit)
}
// ---------------------------------------------------------------------------
// Ghost text (best completion for the input)
// ---------------------------------------------------------------------------
export function bestCompletion(
query: string,
suggestions: SearchSuggestion[]
): string {
if (!query.trim() || suggestions.length === 0) return ""
const top = suggestions[0]!
const q = normalize(query)
const candidates =
top.kind === "contact"
? [top.email, top.displayName]
: [top.email, top.displayName]
for (const c of candidates) {
const cn = normalize(c)
if (cn.startsWith(q)) {
return c.slice(query.length)
}
}
return ""
}
// ---------------------------------------------------------------------------
// filterEmailsBySearchParams
// ---------------------------------------------------------------------------
function withinToMs(within: string): number {
const map: Record<string, number> = {
"1d": 86_400_000,
"3d": 3 * 86_400_000,
"1w": 7 * 86_400_000,
"2w": 14 * 86_400_000,
"1m": 30 * 86_400_000,
"2m": 60 * 86_400_000,
"6m": 180 * 86_400_000,
"1y": 365 * 86_400_000,
}
return map[within] ?? 0
}
function textMatchesEmail(email: Email, text: string): boolean {
const t = normalize(text)
if (!t) return true
const fields = [
email.subject,
email.preview,
email.sender,
email.senderEmail ?? "",
email.body ?? "",
]
for (const f of fields) {
if (normalize(f).includes(t)) return true
}
if (email.conversation) {
for (const msg of email.conversation) {
if (normalize(msg.sender).includes(t)) return true
if (normalize(msg.senderEmail).includes(t)) return true
if (normalize(msg.preview).includes(t)) return true
if (normalize(msg.body).includes(t)) return true
}
}
return false
}
export function filterEmailsBySearchParams(
emails: Email[],
params: SearchParams,
opts?: {
starredIds?: string[]
importantIds?: string[]
}
): Email[] {
const now = Date.now()
return emails.filter((email) => {
if (params.in === "all" && email.spam) return false
if (params.in === "inbox" && !email.labels?.includes("inbox")) return false
if (params.in === "sent" && !email.labels?.includes("sent")) return false
if (params.in === "drafts" && !email.labels?.includes("drafts")) return false
if (params.in === "spam" && !email.spam) return false
if (params.in === "trash" && !email.deleted) return false
if (params.in === "starred") {
const isStarred =
email.starred || (opts?.starredIds?.includes(email.id) ?? false)
if (!isStarred) return false
}
if (params.q && !textMatchesEmail(email, params.q)) return false
if (params.hasWords && !textMatchesEmail(email, params.hasWords))
return false
if (params.doesNotHave) {
if (textMatchesEmail(email, params.doesNotHave)) return false
}
if (params.from) {
const f = normalize(params.from)
const senderMatch =
normalize(email.sender).includes(f) ||
normalize(email.senderEmail ?? "").includes(f)
if (!senderMatch) return false
}
if (params.to) {
const t = normalize(params.to)
let found = false
if (email.conversation) {
for (const msg of email.conversation) {
if (
normalize(msg.sender).includes(t) ||
normalize(msg.senderEmail).includes(t)
)
found = true
}
}
if (
normalize(email.sender).includes(t) ||
normalize(email.senderEmail ?? "").includes(t)
)
found = true
if (!found) return false
}
if (params.subject) {
if (!normalize(email.subject).includes(normalize(params.subject)))
return false
}
if (params.has.includes("attachment") && !email.hasAttachment) return false
if (params.within) {
const ms = withinToMs(params.within)
if (ms > 0) {
const emailDate = new Date(email.date).getTime()
if (now - emailDate > ms) return false
}
}
if (params.after) {
const afterDate = new Date(params.after).getTime()
if (Number.isFinite(afterDate) && new Date(email.date).getTime() < afterDate)
return false
}
if (params.before) {
const beforeDate = new Date(params.before).getTime()
if (Number.isFinite(beforeDate) && new Date(email.date).getTime() > beforeDate)
return false
}
if (params.size) {
const sizeNum = parseFloat(params.size)
if (Number.isFinite(sizeNum) && sizeNum > 0) {
const multiplier = params.sizeUnit === "Mo" ? 1_048_576 : 1024
const thresholdBytes = sizeNum * multiplier
const totalSize =
email.attachments?.reduce((a, att) => a + (att.sizeBytes ?? 0), 0) ?? 0
if (params.sizeOp === "gt" && totalSize <= thresholdBytes) return false
if (params.sizeOp === "lt" && totalSize >= thresholdBytes) return false
}
}
return true
})
}

View File

@ -0,0 +1,125 @@
export interface SearchParams {
q: string
from: string
to: string
subject: string
hasWords: string
doesNotHave: string
has: string[]
after: string
before: string
within: string
size: string
sizeUnit: "Mo" | "Ko"
sizeOp: "gt" | "lt"
/** Folder/label to search in. "all" = all except spam, "all-spam" = everything. */
in: string
excludeChats: boolean
}
export const EMPTY_SEARCH_PARAMS: SearchParams = {
q: "",
from: "",
to: "",
subject: "",
hasWords: "",
doesNotHave: "",
has: [],
after: "",
before: "",
within: "",
size: "",
sizeUnit: "Mo",
sizeOp: "gt",
in: "all",
excludeChats: false,
}
export const DATE_RANGE_OPTIONS = [
{ value: "1d", label: "1 jour" },
{ value: "3d", label: "3 jours" },
{ value: "1w", label: "7 jours" },
{ value: "2w", label: "2 semaines" },
{ value: "1m", label: "1 mois" },
{ value: "2m", label: "2 mois" },
{ value: "6m", label: "6 mois" },
{ value: "1y", label: "1 an" },
] as const
export const SEARCH_IN_OPTIONS = [
{ value: "all", label: "Tous les messages" },
{ value: "all-spam", label: "Tous les messages, Spam et Corbeille inclus" },
{ value: "inbox", label: "Boîte de réception" },
{ value: "starred", label: "Messages suivis" },
{ value: "sent", label: "Messages envoyés" },
{ value: "drafts", label: "Brouillons" },
{ value: "spam", label: "Indésirables" },
{ value: "trash", label: "Corbeille" },
] as const
export function parseSearchParams(urlParams: URLSearchParams): SearchParams {
return {
q: urlParams.get("q") ?? "",
from: urlParams.get("from") ?? "",
to: urlParams.get("to") ?? "",
subject: urlParams.get("subject") ?? "",
hasWords: urlParams.get("hasWords") ?? "",
doesNotHave: urlParams.get("doesNotHave") ?? "",
has: urlParams.getAll("has"),
after: urlParams.get("after") ?? "",
before: urlParams.get("before") ?? "",
within: urlParams.get("within") ?? "",
size: urlParams.get("size") ?? "",
sizeUnit: (urlParams.get("sizeUnit") as "Mo" | "Ko") || "Mo",
sizeOp: (urlParams.get("sizeOp") as "gt" | "lt") || "gt",
in: urlParams.get("in") ?? "all",
excludeChats: urlParams.get("excludeChats") === "true",
}
}
export function searchParamsToURLSearchParams(
params: Partial<SearchParams>
): URLSearchParams {
const out = new URLSearchParams()
if (params.q) out.set("q", params.q)
if (params.from) out.set("from", params.from)
if (params.to) out.set("to", params.to)
if (params.subject) out.set("subject", params.subject)
if (params.hasWords) out.set("hasWords", params.hasWords)
if (params.doesNotHave) out.set("doesNotHave", params.doesNotHave)
if (params.has?.length) {
for (const h of params.has) out.append("has", h)
}
if (params.after) out.set("after", params.after)
if (params.before) out.set("before", params.before)
if (params.within) out.set("within", params.within)
if (params.size) {
out.set("size", params.size)
out.set("sizeUnit", params.sizeUnit ?? "Mo")
out.set("sizeOp", params.sizeOp ?? "gt")
}
if (params.in && params.in !== "all") out.set("in", params.in)
if (params.excludeChats) out.set("excludeChats", "true")
return out
}
export function buildSearchUrl(params: Partial<SearchParams>): string {
const qs = searchParamsToURLSearchParams(params).toString()
return `/mail/search${qs ? `?${qs}` : ""}`
}
export function isSearchEmpty(params: SearchParams): boolean {
return (
!params.q &&
!params.from &&
!params.to &&
!params.subject &&
!params.hasWords &&
!params.doesNotHave &&
params.has.length === 0 &&
!params.after &&
!params.before &&
!params.within &&
!params.size
)
}

View File

@ -9,6 +9,8 @@ export const DEFAULT_MAIL_FOLDER = "inbox"
export const DEFAULT_INBOX_TAB = "primary"
/** Onglet boîte : tous les messages inbox sans filtre libellé catégorie. */
export const INBOX_ALL_TAB = "all"
/** Pseudo-dossier pour les résultats de recherche (query params dans l'URL). */
export const SEARCH_FOLDER_ID = "search"
/** Onglets sans pastille « nouveaux » ni ligne expéditeurs quand inactifs. */
export function inboxTabShowsInactiveMeta(tabId: string): boolean {

View File

@ -209,6 +209,7 @@ const STATIC_NAV_FOLDER_LABELS: Record<string, string> = {
scheduled: "Planifié",
spam: "Indésirables",
trash: "Corbeille",
search: "Recherche",
}
/** Libellé lisible pour id de ligne (liste vide, messages détat). Dossiers / libellés IMAP viennent de larbre. */

View File

@ -0,0 +1,66 @@
"use client"
import { create } from "zustand"
interface MailSearchState {
inputValue: string
dropdownOpen: boolean
selectedIndex: number
advancedOpen: boolean
/** Filter chips active in the dropdown (before submitting to URL). */
chipAttachment: boolean
chipLast7Days: boolean
chipFromMe: boolean
}
interface MailSearchActions {
setInputValue: (value: string) => void
setDropdownOpen: (open: boolean) => void
setSelectedIndex: (index: number) => void
setAdvancedOpen: (open: boolean) => void
toggleChipAttachment: () => void
toggleChipLast7Days: () => void
toggleChipFromMe: () => void
resetChips: () => void
reset: () => void
}
const INITIAL: MailSearchState = {
inputValue: "",
dropdownOpen: false,
selectedIndex: -1,
advancedOpen: false,
chipAttachment: false,
chipLast7Days: false,
chipFromMe: false,
}
export const useMailSearchStore = create<MailSearchState & MailSearchActions>()(
(set) => ({
...INITIAL,
setInputValue: (value) =>
set({ inputValue: value, dropdownOpen: value.length > 0, selectedIndex: -1 }),
setDropdownOpen: (open) => set({ dropdownOpen: open }),
setSelectedIndex: (index) => set({ selectedIndex: index }),
setAdvancedOpen: (open) =>
set({ advancedOpen: open, dropdownOpen: false }),
toggleChipAttachment: () =>
set((s) => ({ chipAttachment: !s.chipAttachment })),
toggleChipLast7Days: () =>
set((s) => ({ chipLast7Days: !s.chipLast7Days })),
toggleChipFromMe: () =>
set((s) => ({ chipFromMe: !s.chipFromMe })),
resetChips: () =>
set({ chipAttachment: false, chipLast7Days: false, chipFromMe: false }),
reset: () => set(INITIAL),
})
)

File diff suppressed because one or more lines are too long