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 { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
|
||||||
import { useMailSplitView } from "@/hooks/use-mail-split-view"
|
import { useMailSplitView } from "@/hooks/use-mail-split-view"
|
||||||
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
|
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 type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
|
||||||
import { MailToaster } from "@/components/gmail/mail-toaster"
|
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 { Sidebar } from "@/components/gmail/sidebar"
|
||||||
import { Header } from "@/components/gmail/header"
|
import { Header } from "@/components/gmail/header"
|
||||||
import { EmailList } from "@/components/gmail/email-list"
|
import { EmailList } from "@/components/gmail/email-list"
|
||||||
@ -49,6 +50,7 @@ function segmentsFromPathname(pathname: string | null): string[] | undefined {
|
|||||||
function MailAppInner() {
|
function MailAppInner() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const currentSearchParams = useSearchParams()
|
||||||
const segments = useMemo(() => segmentsFromPathname(pathname), [pathname])
|
const segments = useMemo(() => segmentsFromPathname(pathname), [pathname])
|
||||||
const route = useMemo(() => parseMailSegments(segments), [segments])
|
const route = useMemo(() => parseMailSegments(segments), [segments])
|
||||||
|
|
||||||
@ -68,12 +70,15 @@ function MailAppInner() {
|
|||||||
}, [isXs])
|
}, [isXs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (route.folderId !== "search") {
|
||||||
pushRecentFolderVisit(mailNavVisitKey(route.folderId, route.inboxTab))
|
pushRecentFolderVisit(mailNavVisitKey(route.folderId, route.inboxTab))
|
||||||
|
}
|
||||||
}, [route.folderId, route.inboxTab, pushRecentFolderVisit])
|
}, [route.folderId, route.inboxTab, pushRecentFolderVisit])
|
||||||
const [folderUnreadCounts, setFolderUnreadCounts] = useState<
|
const [folderUnreadCounts, setFolderUnreadCounts] = useState<
|
||||||
Record<string, number>
|
Record<string, number>
|
||||||
>({})
|
>({})
|
||||||
const [xsViewChrome, setXsViewChrome] = useState<MailXsViewChrome | null>(null)
|
const [xsViewChrome, setXsViewChrome] = useState<MailXsViewChrome | null>(null)
|
||||||
|
const [mobileSearchOpen, setMobileSearchOpen] = useState(false)
|
||||||
|
|
||||||
const navigateRoute = useCallback(
|
const navigateRoute = useCallback(
|
||||||
(patch: Partial<MailRouteState>) => {
|
(patch: Partial<MailRouteState>) => {
|
||||||
@ -86,9 +91,13 @@ function MailAppInner() {
|
|||||||
page: patch.page !== undefined ? patch.page : route.page,
|
page: patch.page !== undefined ? patch.page : route.page,
|
||||||
mailId: patch.mailId !== undefined ? patch.mailId : route.mailId,
|
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(
|
const handleSelectFolder = useCallback(
|
||||||
@ -123,6 +132,7 @@ function MailAppInner() {
|
|||||||
isXs={false}
|
isXs={false}
|
||||||
sidebarCollapsed={sidebarCollapsed || touchNav}
|
sidebarCollapsed={sidebarCollapsed || touchNav}
|
||||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
onOpenMobileSearch={() => setMobileSearchOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -194,8 +204,16 @@ function MailAppInner() {
|
|||||||
sidebarOpen={!sidebarCollapsed}
|
sidebarOpen={!sidebarCollapsed}
|
||||||
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
|
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
|
||||||
xsViewChrome={xsViewChrome}
|
xsViewChrome={xsViewChrome}
|
||||||
|
onOpenSearch={() => setMobileSearchOpen(true)}
|
||||||
|
searchQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""}
|
||||||
|
onClearSearch={() => router.push("/mail/inbox")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
<MobileSearchOverlay
|
||||||
|
open={mobileSearchOpen}
|
||||||
|
onClose={() => setMobileSearchOpen(false)}
|
||||||
|
initialQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SidebarNavProvider>
|
</SidebarNavProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -52,9 +52,11 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Pencil,
|
Pencil,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
CalendarX2,
|
||||||
X,
|
X,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Inbox as InboxIcon,
|
Inbox as InboxIcon,
|
||||||
|
User as UserIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
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 { resolveOpenEmailView } from "@/lib/mail-settings/resolve-open-email"
|
||||||
import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails"
|
import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails"
|
||||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||||
|
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||||
|
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
|
||||||
import {
|
import {
|
||||||
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
|
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
|
||||||
MAIL_MENU_SURFACE_CLASS,
|
MAIL_MENU_SURFACE_CLASS,
|
||||||
@ -152,9 +156,18 @@ import {
|
|||||||
import {
|
import {
|
||||||
DEFAULT_INBOX_TAB,
|
DEFAULT_INBOX_TAB,
|
||||||
INBOX_ALL_TAB,
|
INBOX_ALL_TAB,
|
||||||
|
SEARCH_FOLDER_ID,
|
||||||
inboxTabShowsInactiveMeta,
|
inboxTabShowsInactiveMeta,
|
||||||
normalizeInboxTabSegment,
|
normalizeInboxTabSegment,
|
||||||
} from "@/lib/mail-url"
|
} 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 { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
|
||||||
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
|
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
|
||||||
import { ContactHoverCard } from "./contact-hover-card"
|
import { ContactHoverCard } from "./contact-hover-card"
|
||||||
@ -625,6 +638,42 @@ export function EmailList({
|
|||||||
}: EmailListProps) {
|
}: EmailListProps) {
|
||||||
const isViewMode = openMailId !== null && !splitView
|
const isViewMode = openMailId !== null && !splitView
|
||||||
const showSplitReadingPane = splitView && openMailId !== null
|
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 { savedThreadReplyDrafts } = useComposeDrafts()
|
||||||
const {
|
const {
|
||||||
@ -979,6 +1028,14 @@ export function EmailList({
|
|||||||
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
|
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSearchMode && searchParams) {
|
||||||
|
return filterEmailsBySearchParams(visible, searchParams, {
|
||||||
|
starredIds: starredEmails,
|
||||||
|
importantIds: importantEmails,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let rows = visible.filter((email) =>
|
let rows = visible.filter((email) =>
|
||||||
emailMatchesFolder(
|
emailMatchesFolder(
|
||||||
email,
|
email,
|
||||||
@ -1029,6 +1086,10 @@ export function EmailList({
|
|||||||
notSpamEmailIds,
|
notSpamEmailIds,
|
||||||
allEmails,
|
allEmails,
|
||||||
navMaps,
|
navMaps,
|
||||||
|
isSearchMode,
|
||||||
|
searchParams,
|
||||||
|
starredEmails,
|
||||||
|
importantEmails,
|
||||||
])
|
])
|
||||||
|
|
||||||
const displayListEmails = useMemo(() => {
|
const displayListEmails = useMemo(() => {
|
||||||
@ -1075,6 +1136,7 @@ export function EmailList({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const mobileFolderLabel = useMemo(() => {
|
const mobileFolderLabel = useMemo(() => {
|
||||||
|
if (isSearchMode) return "Résultats de recherche"
|
||||||
const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
|
const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
|
||||||
return selectedFolder === "inbox" && inboxTabNorm !== "primary"
|
return selectedFolder === "inbox" && inboxTabNorm !== "primary"
|
||||||
? inboxCategoryTabLabel
|
? inboxCategoryTabLabel
|
||||||
@ -1084,6 +1146,7 @@ export function EmailList({
|
|||||||
inboxTab,
|
inboxTab,
|
||||||
inboxCategoryTabLabel,
|
inboxCategoryTabLabel,
|
||||||
sidebarNav.folderIdToLabel,
|
sidebarNav.folderIdToLabel,
|
||||||
|
isSearchMode,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -3226,14 +3289,173 @@ export function EmailList({
|
|||||||
</div>
|
</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 className={cn("relative flex min-h-0 flex-1 flex-col")}>
|
||||||
<div
|
<div
|
||||||
ref={listViewportRef}
|
ref={listViewportRef}
|
||||||
className={cn(
|
className={cn("max-sm:pb-16",
|
||||||
!splitView && isViewMode && openEmail
|
!splitView && isViewMode && openEmail
|
||||||
? "relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
? "relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
: mainScrollClass,
|
: mainScrollClass,
|
||||||
"relative min-h-0 flex-1 overscroll-y-none max-sm:pb-16"
|
"relative min-h-0 flex-1 overscroll-y-none"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{listToolbarMode && (
|
{listToolbarMode && (
|
||||||
@ -3254,7 +3476,7 @@ export function EmailList({
|
|||||||
<div
|
<div
|
||||||
ref={pullContentRef}
|
ref={pullContentRef}
|
||||||
className={cn(
|
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)]"
|
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">
|
<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>
|
<p className="text-sm text-[#5f6368]">Aucun message planifié.</p>
|
||||||
</div>
|
</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">
|
<Empty className="min-h-[240px] flex-1 border-0 bg-mail-surface py-10 shadow-none">
|
||||||
<EmptyHeader className="max-w-md">
|
<EmptyHeader className="max-w-md">
|
||||||
|
|||||||
@ -9,11 +9,10 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onToggleSidebar: () => void
|
onToggleSidebar: () => void
|
||||||
/** Match `<main>` horizontal offset (same width as sidebar rail spacer). */
|
|
||||||
sidebarCollapsed: boolean
|
sidebarCollapsed: boolean
|
||||||
isXs?: boolean
|
isXs?: boolean
|
||||||
/** Split pane shows search over the list column only. */
|
|
||||||
hideSearch?: boolean
|
hideSearch?: boolean
|
||||||
|
onOpenMobileSearch?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
@ -21,6 +20,7 @@ export function Header({
|
|||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
isXs = false,
|
isXs = false,
|
||||||
hideSearch = false,
|
hideSearch = false,
|
||||||
|
onOpenMobileSearch,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
return (
|
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">
|
<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"
|
size="icon"
|
||||||
className="size-12 shrink-0 rounded-full border border-[#d3e3fd] bg-[#eaf1fb] text-gray-500 hover:bg-[#dfe9f7] sm:hidden"
|
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"
|
aria-label="Rechercher dans les messages"
|
||||||
|
onClick={onOpenMobileSearch}
|
||||||
>
|
>
|
||||||
<Search className="size-5 shrink-0 ml-0.5" />
|
<Search className="size-5 shrink-0 ml-0.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{!hideSearch ? (
|
{!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 />
|
<MailSearchBar />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,19 +1,447 @@
|
|||||||
"use client"
|
"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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
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 { 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 {
|
interface MailSearchBarProps {
|
||||||
className?: string
|
className?: string
|
||||||
/** Split-pane column: balanced icon inset inside the pill. */
|
|
||||||
compact?: boolean
|
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 (
|
return (
|
||||||
<div className={cn("relative flex w-full min-w-0 items-center", className)}>
|
<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>
|
||||||
|
</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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute flex items-center text-gray-500",
|
"pointer-events-none absolute flex items-center text-gray-500",
|
||||||
@ -22,22 +450,255 @@ export function MailSearchBar({ className, compact = false }: MailSearchBarProps
|
|||||||
>
|
>
|
||||||
<Search className="size-5 shrink-0" />
|
<Search className="size-5 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<Input
|
|
||||||
|
{/* 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"
|
type="text"
|
||||||
placeholder="Rechercher dans les messages"
|
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(
|
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",
|
"h-12 w-full rounded-full border-0 bg-muted text-sm outline-none placeholder:text-gray-500",
|
||||||
compact ? "pl-11 pr-11" : "pl-11 pr-12"
|
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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn("absolute text-gray-600", compact ? "right-3" : "right-2")}
|
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"
|
aria-label="Filtres de recherche"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
const current = useMailSearchStore.getState().advancedOpen
|
||||||
|
setAdvancedOpen(!current)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SlidersHorizontal className="h-5 w-5" />
|
<SlidersHorizontal className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react"
|
|
||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
|
||||||
X,
|
X,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Search,
|
||||||
Archive,
|
Archive,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Reply,
|
Reply,
|
||||||
@ -27,6 +26,9 @@ interface MobileBottomBarProps {
|
|||||||
onToggleSidebar: () => void
|
onToggleSidebar: () => void
|
||||||
/** Lecture message xs : barre d’actions à la place du menu / recherche. */
|
/** Lecture message xs : barre d’actions à la place du menu / recherche. */
|
||||||
xsViewChrome?: MailXsViewChrome | null
|
xsViewChrome?: MailXsViewChrome | null
|
||||||
|
onOpenSearch?: () => void
|
||||||
|
searchQuery?: string
|
||||||
|
onClearSearch?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROUNDED_BAR_BTN =
|
const ROUNDED_BAR_BTN =
|
||||||
@ -36,31 +38,19 @@ export function MobileBottomBar({
|
|||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
xsViewChrome = null,
|
xsViewChrome = null,
|
||||||
|
onOpenSearch,
|
||||||
|
searchQuery,
|
||||||
|
onClearSearch,
|
||||||
}: MobileBottomBarProps) {
|
}: MobileBottomBarProps) {
|
||||||
const [searchValue, setSearchValue] = useState("")
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const { openCompose } = useComposeActions()
|
const { openCompose } = useComposeActions()
|
||||||
const inMailView = Boolean(xsViewChrome)
|
const inMailView = Boolean(xsViewChrome)
|
||||||
|
|
||||||
const hasSearch = searchValue.length > 0
|
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
setSearchValue("")
|
|
||||||
inputRef.current?.focus()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sidebarOpen) {
|
|
||||||
inputRef.current?.blur()
|
|
||||||
}
|
|
||||||
}, [sidebarOpen])
|
|
||||||
|
|
||||||
return (
|
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="fixed inset-x-0 bottom-0 z-50 flex flex-col items-center pb-[env(safe-area-inset-bottom)] sm:hidden">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"pointer-events-none absolute inset-0 bg-gradient-to-t to-transparent",
|
"pointer-events-none absolute inset-0 bg-gradient-to-t to-transparent",
|
||||||
inMailView
|
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"
|
: "from-mail-surface/95 via-mail-surface/70 dark:from-background/95 dark:via-background/70"
|
||||||
)} />
|
)} />
|
||||||
|
|
||||||
@ -131,19 +121,22 @@ export function MobileBottomBar({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!sidebarOpen && (
|
{!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">
|
<div className="pointer-events-none absolute left-3 z-10 flex items-center text-gray-500">
|
||||||
<Search className="size-5" />
|
<Search className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div
|
||||||
ref={inputRef}
|
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"
|
||||||
type="text"
|
>
|
||||||
value={searchValue}
|
<span className={searchQuery ? "truncate text-gray-900 dark:text-gray-100" : "text-gray-400"}>
|
||||||
onChange={(e) => setSearchValue(e.target.value)}
|
{searchQuery || "Rechercher"}
|
||||||
placeholder="Rechercher"
|
</span>
|
||||||
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>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -153,10 +146,10 @@ export function MobileBottomBar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(ROUNDED_BAR_BTN, inMailView && "ml-auto")}
|
className={cn(ROUNDED_BAR_BTN, inMailView && "ml-auto")}
|
||||||
onClick={inMailView || !hasSearch ? openCompose : handleClear}
|
onClick={searchQuery ? onClearSearch : openCompose}
|
||||||
aria-label={!inMailView && hasSearch ? "Effacer la recherche" : "Nouveau message"}
|
aria-label={searchQuery ? "Quitter la recherche" : "Nouveau message"}
|
||||||
>
|
>
|
||||||
{!inMailView && hasSearch ? (
|
{searchQuery ? (
|
||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
) : (
|
) : (
|
||||||
<Pencil 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])
|
}, [folderIdToLabel])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!validNavFolderIds.has(selectedFolder)) {
|
if (selectedFolder !== "search" && !validNavFolderIds.has(selectedFolder)) {
|
||||||
onSelectFolder("inbox")
|
onSelectFolder("inbox")
|
||||||
}
|
}
|
||||||
}, [validNavFolderIds, selectedFolder, onSelectFolder])
|
}, [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_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS = "dark:!text-white"
|
||||||
|
|
||||||
export const MAIL_PREVIEW_SCROLL_CLASS =
|
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] " +
|
"[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"
|
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground/45"
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
|
Search,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
folderTreeNavIconNameClosed,
|
folderTreeNavIconNameClosed,
|
||||||
@ -30,6 +31,7 @@ const SYSTEM_ICONS: Record<string, LucideIcon> = {
|
|||||||
scheduled: ClockArrowUp,
|
scheduled: ClockArrowUp,
|
||||||
spam: ShieldAlert,
|
spam: ShieldAlert,
|
||||||
trash: Trash2,
|
trash: Trash2,
|
||||||
|
search: Search,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MailNavIcon =
|
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"
|
export const DEFAULT_INBOX_TAB = "primary"
|
||||||
/** Onglet boîte : tous les messages inbox sans filtre libellé catégorie. */
|
/** Onglet boîte : tous les messages inbox sans filtre libellé catégorie. */
|
||||||
export const INBOX_ALL_TAB = "all"
|
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. */
|
/** Onglets sans pastille « nouveaux » ni ligne expéditeurs quand inactifs. */
|
||||||
export function inboxTabShowsInactiveMeta(tabId: string): boolean {
|
export function inboxTabShowsInactiveMeta(tabId: string): boolean {
|
||||||
|
|||||||
@ -209,6 +209,7 @@ const STATIC_NAV_FOLDER_LABELS: Record<string, string> = {
|
|||||||
scheduled: "Planifié",
|
scheduled: "Planifié",
|
||||||
spam: "Indésirables",
|
spam: "Indésirables",
|
||||||
trash: "Corbeille",
|
trash: "Corbeille",
|
||||||
|
search: "Recherche",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Libellé lisible pour id de ligne (liste vide, messages d’état). Dossiers / libellés IMAP viennent de l’arbre. */
|
/** 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