Huhu
This commit is contained in:
parent
9266aa34cd
commit
aad897b617
@ -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>
|
||||
)
|
||||
|
||||
@ -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'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'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'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">
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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 «
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{inputValue}
|
||||
</span>
|
||||
»
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 d’actions à 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" />
|
||||
|
||||
522
components/gmail/mobile-search-overlay.tsx
Normal file
522
components/gmail/mobile-search-overlay.tsx
Normal 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 «
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{inputValue}
|
||||
</span>
|
||||
»
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -884,7 +884,7 @@ export function Sidebar({
|
||||
}, [folderIdToLabel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!validNavFolderIds.has(selectedFolder)) {
|
||||
if (selectedFolder !== "search" && !validNavFolderIds.has(selectedFolder)) {
|
||||
onSelectFolder("inbox")
|
||||
}
|
||||
}, [validNavFolderIds, selectedFolder, onSelectFolder])
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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 =
|
||||
|
||||
296
lib/mail-search/search-engine.ts
Normal file
296
lib/mail-search/search-engine.ts
Normal 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
|
||||
})
|
||||
}
|
||||
125
lib/mail-search/search-params.ts
Normal file
125
lib/mail-search/search-params.ts
Normal 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
|
||||
)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 l’arbre. */
|
||||
|
||||
66
lib/stores/mail-search-store.ts
Normal file
66
lib/stores/mail-search-store.ts
Normal 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
Loading…
Reference in New Issue
Block a user