Refactor and enhance mobile experience with new components and layout adjustments. Updated TypeScript configurations, improved sidebar navigation, and added search functionality. Enhanced email view and invitation handling for better user interaction.

This commit is contained in:
R3D347HR4Y 2026-05-18 00:17:51 +02:00
parent 489c0d0c5c
commit 1fc4de1873
24 changed files with 1790 additions and 549 deletions

View File

@ -235,3 +235,45 @@
outline: 1px solid rgba(26, 115, 232, 0.4); outline: 1px solid rgba(26, 115, 232, 0.4);
outline-offset: -1px; outline-offset: -1px;
} }
/* Mail shell: dynamic viewport height (tablet Safari chrome) + no document scroll */
html,
body {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
overscroll-behavior: none;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/* Mail UI: text selection only in fields and message previews */
.ultimail-app {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
overscroll-behavior: none;
touch-action: manipulation;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
.ultimail-app input,
.ultimail-app textarea,
.ultimail-app select,
.ultimail-app [contenteditable="true"],
.ultimail-app [contenteditable=""],
.ultimail-app .tiptap,
.ultimail-app [data-selectable-text],
.ultimail-app [data-selectable-text] * {
-webkit-user-select: text;
user-select: text;
-webkit-touch-callout: default;
}
.ultimail-app [data-sidebar] {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}

View File

@ -1,4 +1,4 @@
import type { Metadata } from 'next' import type { Metadata, Viewport } from 'next'
import { Geist, Geist_Mono } from 'next/font/google' import { Geist, Geist_Mono } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next' import { Analytics } from '@vercel/analytics/next'
import './globals.css' import './globals.css'
@ -12,14 +12,23 @@ export const metadata: Metadata = {
generator: 'v0.app', generator: 'v0.app',
} }
/** Fit visible viewport on tablet/mobile; disable pinch/double-tap zoom on the shell. */
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: 'cover',
}
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="en" className="bg-white"> <html lang="en" className="h-dvh max-h-dvh overflow-hidden bg-white">
<body className="font-sans antialiased"> <body className="h-dvh max-h-dvh overflow-hidden font-sans antialiased touch-manipulation">
{children} {children}
{process.env.NODE_ENV === 'production' && <Analytics />} {process.env.NODE_ENV === 'production' && <Analytics />}
</body> </body>

View File

@ -9,7 +9,9 @@ import {
useState, useState,
type CSSProperties, type CSSProperties,
} from "react" } from "react"
import { readXsMatches, useIsXs } from "@/hooks/use-xs" import { useIsXs } from "@/hooks/use-xs"
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import { useMailSplitView } from "@/hooks/use-mail-split-view"
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar" import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
import { Toaster } from "sonner" import { Toaster } from "sonner"
import { useRouter, usePathname } from "next/navigation" import { useRouter, usePathname } from "next/navigation"
@ -46,12 +48,14 @@ function MailAppInner() {
const route = useMemo(() => parseMailSegments(segments), [segments]) const route = useMemo(() => parseMailSegments(segments), [segments])
const isXs = useIsXs() const isXs = useIsXs()
const touchNav = useTouchNav()
const splitView = useMailSplitView()
const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit) const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit)
/** Start closed so narrow viewports match SSR/CSS before JS runs; desktop opens in layout. */ /** Start closed so narrow viewports match SSR/CSS before JS runs; desktop opens in layout. */
const [sidebarCollapsed, setSidebarCollapsed] = useState(true) const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
useLayoutEffect(() => { useLayoutEffect(() => {
if (!readXsMatches()) setSidebarCollapsed(false) if (!readTouchNavMatches()) setSidebarCollapsed(false)
}, []) }, [])
useEffect(() => { useEffect(() => {
@ -89,7 +93,7 @@ function MailAppInner() {
page: 1, page: 1,
mailId: null, mailId: null,
}) })
if (readXsMatches()) setSidebarCollapsed(true) if (readTouchNavMatches()) setSidebarCollapsed(true)
}, },
[navigateRoute] [navigateRoute]
) )
@ -106,28 +110,32 @@ function MailAppInner() {
}) })
} }
> >
<div className="flex h-screen flex-col bg-app-canvas"> <div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
<div className="hidden sm:block"> {!splitView ? (
<Header <div className="hidden sm:block">
isXs={false} <Header
sidebarCollapsed={sidebarCollapsed} isXs={false}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} sidebarCollapsed={sidebarCollapsed || touchNav}
/> onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
</div> />
</div>
) : null}
<div className="relative flex min-h-0 flex-1 gap-0 overflow-hidden bg-app-canvas pl-0 pr-0 pb-1 pt-1 sm:gap-1 sm:pl-1"> <div className="relative flex min-h-0 flex-1 gap-0 overflow-hidden bg-app-canvas pl-0 pr-0 pb-1 pt-1 sm:gap-1 sm:pl-1">
{!sidebarCollapsed && ( {!sidebarCollapsed && touchNav && (
<button <button
type="button" type="button"
aria-label="Fermer le menu" aria-label="Fermer le menu"
className="absolute inset-0 z-30 bg-black/20 sm:hidden" className="absolute inset-0 z-30 bg-black/20"
onClick={() => setSidebarCollapsed(true)} onClick={() => setSidebarCollapsed(true)}
/> />
)} )}
<div <div
className={ className={
sidebarCollapsed touchNav && isXs
? "w-0 shrink-0 sm:w-[68px]" ? "w-0 shrink-0"
: "w-0 shrink-0 sm:w-60" : touchNav || sidebarCollapsed
? "w-0 shrink-0 sm:w-[68px]"
: "w-0 shrink-0 sm:w-60"
} }
/> />
<Sidebar <Sidebar
@ -135,6 +143,7 @@ function MailAppInner() {
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
folderUnreadCounts={folderUnreadCounts} folderUnreadCounts={folderUnreadCounts}
splitView={splitView}
/> />
<main className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-none bg-white shadow-sm sm:rounded-2xl"> <main className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-none bg-white shadow-sm sm:rounded-2xl">
<Suspense> <Suspense>
@ -143,6 +152,8 @@ function MailAppInner() {
inboxTab={route.inboxTab} inboxTab={route.inboxTab}
listPage={route.page} listPage={route.page}
openMailId={route.mailId} openMailId={route.mailId}
splitView={splitView}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
onMailRouteNavigate={navigateRoute} onMailRouteNavigate={navigateRoute}
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
onFolderUnreadCountsChange={setFolderUnreadCounts} onFolderUnreadCountsChange={setFolderUnreadCounts}
@ -151,10 +162,12 @@ function MailAppInner() {
</main> </main>
<RightPanel /> <RightPanel />
</div> </div>
<MobileBottomBar {!splitView ? (
sidebarOpen={!sidebarCollapsed} <MobileBottomBar
onToggleSidebar={() => setSidebarCollapsed((c) => !c)} sidebarOpen={!sidebarCollapsed}
/> onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
/>
) : null}
</div> </div>
</SidebarNavProvider> </SidebarNavProvider>
) )
@ -165,13 +178,25 @@ export function MailAppShell({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
useEffect(() => {
const blockPinch = (event: Event) => event.preventDefault()
document.addEventListener("gesturestart", blockPinch, { passive: false })
document.addEventListener("gesturechange", blockPinch, { passive: false })
document.addEventListener("gestureend", blockPinch, { passive: false })
return () => {
document.removeEventListener("gesturestart", blockPinch)
document.removeEventListener("gesturechange", blockPinch)
document.removeEventListener("gestureend", blockPinch)
}
}, [])
return ( return (
<ComposeProvider> <ComposeProvider>
<ScheduledMailProvider> <ScheduledMailProvider>
<EmailDragProvider> <EmailDragProvider>
<Suspense <Suspense
fallback={ fallback={
<div className="flex h-screen flex-col bg-app-canvas"> <div className="flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
<div className="hidden h-14 shrink-0 border-b border-gray-200 bg-white sm:block" /> <div className="hidden h-14 shrink-0 border-b border-gray-200 bg-white sm:block" />
<div className="min-h-0 flex-1 bg-app-canvas" /> <div className="min-h-0 flex-1 bg-app-canvas" />
</div> </div>

View File

@ -1,12 +1,12 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { InvitationTimeChipText } from "@/components/gmail/invitation-time-chip-text"
import { Icon } from "@iconify/react" import { Icon } from "@iconify/react"
import { ThumbsDown, ThumbsUp, Users, MoreVertical } from "lucide-react" import { ThumbsDown, ThumbsUp, Users, MoreVertical } from "lucide-react"
import { import {
VIDEO_CONFERENCE_LOGOS, VIDEO_CONFERENCE_LOGOS,
formatInvitationAttendeeLine, formatInvitationAttendeeLine,
formatInvitationTimeChip,
type ParsedCalendarInvitation, type ParsedCalendarInvitation,
} from "@/lib/calendar-invitation" } from "@/lib/calendar-invitation"
import { ensureVcLogosCollection } from "@/lib/register-vc-logos" import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
@ -51,11 +51,6 @@ export function CalendarInvitationPreview({
}) { }) {
ensureVcLogosCollection() ensureVcLogosCollection()
const timeChip = useMemo(
() => formatInvitationTimeChip(invitation.start, invitation.end),
[invitation.start, invitation.end]
)
const { organizerLine, othersLine } = useMemo( const { organizerLine, othersLine } = useMemo(
() => attendeeDisplayList(invitation), () => attendeeDisplayList(invitation),
[invitation] [invitation]
@ -76,7 +71,10 @@ export function CalendarInvitationPreview({
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-[#5f6368]"> <div className="flex flex-wrap items-center gap-2 text-sm text-[#5f6368]">
<Icon icon={confIcon} className="size-5 shrink-0" aria-hidden /> <Icon icon={confIcon} className="size-5 shrink-0" aria-hidden />
<span>{timeChip}</span> <InvitationTimeChipText
start={invitation.start}
end={invitation.end}
/>
</div> </div>
<h2 className="text-xl font-normal leading-snug text-[#202124]"> <h2 className="text-xl font-normal leading-snug text-[#202124]">
{invitation.summary} {invitation.summary}

View File

@ -11,6 +11,7 @@ import {
Suspense, Suspense,
} from "react" } from "react"
import { useIsXs } from "@/hooks/use-xs" import { useIsXs } from "@/hooks/use-xs"
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useEditor, EditorContent } from "@tiptap/react" import { useEditor, EditorContent } from "@tiptap/react"
import { Editor, Node as TipTapNode, mergeAttributes, type Extensions } from "@tiptap/core" import { Editor, Node as TipTapNode, mergeAttributes, type Extensions } from "@tiptap/core"
@ -1697,7 +1698,9 @@ export function ComposeWindow({
: cn( : cn(
"rounded-t-lg shadow-[0_-2px_8px_rgba(0,0,0,0.08),_-4px_0_12px_rgba(0,0,0,0.12),_4px_0_12px_rgba(0,0,0,0.12)]", "rounded-t-lg shadow-[0_-2px_8px_rgba(0,0,0,0.08),_-4px_0_12px_rgba(0,0,0,0.12),_4px_0_12px_rgba(0,0,0,0.12)]",
compose.maximized compose.maximized
? "fixed inset-12 z-60 rounded-lg" ? readCoarsePointerMatches()
? "fixed inset-0 z-60 rounded-none"
: "fixed inset-12 z-60 rounded-lg"
: "h-[480px] w-[500px]" : "h-[480px] w-[500px]"
) )
)} )}

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ import {
Reply, Reply,
ReplyAll, ReplyAll,
Forward, Forward,
Smile,
MoreVertical, MoreVertical,
Printer, Printer,
ExternalLink, ExternalLink,
@ -68,9 +67,13 @@ import {
useComposeWindows, useComposeWindows,
DEFAULT_IDENTITIES, DEFAULT_IDENTITIES,
type ThreadComposeKind, type ThreadComposeKind,
type ComposeOpenPreset,
savedThreadDraftToComposePreset, savedThreadDraftToComposePreset,
} from "@/lib/compose-context" } from "@/lib/compose-context"
import { buildThreadComposePreset } from "@/lib/thread-compose-preset" import {
buildThreadComposePreset,
withTouchFullscreenComposePreset,
} from "@/lib/thread-compose-preset"
import { openConversationPrint } from "@/lib/print-conversation" import { openConversationPrint } from "@/lib/print-conversation"
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation" import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
import { ComposeWindow } from "@/components/gmail/compose-modal" import { ComposeWindow } from "@/components/gmail/compose-modal"
@ -110,6 +113,19 @@ const LABEL_DISPLAY_NAMES: Record<string, string> = {
const MESSAGE_MORE_MENU_CLASS = const MESSAGE_MORE_MENU_CLASS =
"min-w-[280px] rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg [&_[data-slot=dropdown-menu-item]]:gap-3 [&_[data-slot=dropdown-menu-item]]:rounded-none [&_[data-slot=dropdown-menu-item]]:px-3 [&_[data-slot=dropdown-menu-item]]:py-2 [&_[data-slot=dropdown-menu-item]]:text-sm [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1 [&_[data-slot=dropdown-menu-separator]]:bg-[#eceff1]" "min-w-[280px] rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg [&_[data-slot=dropdown-menu-item]]:gap-3 [&_[data-slot=dropdown-menu-item]]:rounded-none [&_[data-slot=dropdown-menu-item]]:px-3 [&_[data-slot=dropdown-menu-item]]:py-2 [&_[data-slot=dropdown-menu-item]]:text-sm [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1 [&_[data-slot=dropdown-menu-separator]]:bg-[#eceff1]"
/** Scroll zone du corps du message (preview remplit le panneau parent). */
const EMAIL_PREVIEW_SCROLL_CLASS =
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain outline-none " +
"[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " +
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white " +
"[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white [&::-webkit-scrollbar-track]:shadow-none " +
"[&::-webkit-scrollbar-thumb]:rounded-none [&::-webkit-scrollbar-thumb]:border-0 [&::-webkit-scrollbar-thumb]:shadow-none " +
"[&::-webkit-scrollbar-thumb]:bg-[#9aa0a6] hover:[&::-webkit-scrollbar-thumb]:bg-[#5f6368] " +
"[&::-webkit-scrollbar-corner]:border-0 [&::-webkit-scrollbar-corner]:bg-white"
const REPLY_BAR_SURFACE_CLASS =
"bg-[linear-gradient(to_bottom,rgba(255,255,255,0)_0%,#ffffff_0.75rem,#ffffff_100%)] pt-3"
/* ── Sandboxed iframe for HTML body ── */ /* ── Sandboxed iframe for HTML body ── */
function SandboxedContent({ function SandboxedContent({
@ -437,7 +453,7 @@ function CollapsedMessage({
> >
{senderInitial(name)} {senderInitial(name)}
</div> </div>
<div className="min-w-0 flex-1 flex flex-col gap-1"> <div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<div className="flex min-w-0 items-center justify-between gap-2"> <div className="flex min-w-0 items-center justify-between gap-2">
<ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0"> <ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0">
<span className="truncate text-sm font-semibold text-[#202124]">{name}</span> <span className="truncate text-sm font-semibold text-[#202124]">{name}</span>
@ -513,7 +529,7 @@ function ExpandedMessage({
</div> </div>
)} )}
<div className="min-w-0 flex-1 flex flex-col gap-1"> <div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<div className="min-w-0 truncate text-sm leading-snug"> <div className="min-w-0 truncate text-sm leading-snug">
<ContactHoverCard <ContactHoverCard
displayName={sender} displayName={sender}
@ -698,6 +714,7 @@ function ExpandedMessage({
"px-4 pl-[68px]", "px-4 pl-[68px]",
attachments.length > 0 ? "pb-0" : "pb-4" attachments.length > 0 ? "pb-0" : "pb-4"
)} )}
data-selectable-text
> >
<SandboxedContent html={body} isSpam={isSpam} /> <SandboxedContent html={body} isSpam={isSpam} />
</div> </div>
@ -806,13 +823,15 @@ export function EmailView({
const savedThreadDraft = savedThreadReplyDrafts[email.id] const savedThreadDraft = savedThreadReplyDrafts[email.id]
const hasInlineForThread = Boolean(inlineCompose) const hasInlineForThread = Boolean(inlineCompose)
const showReplyForwardBar = !inlineCompose
const threadComposeFooterRef = useRef<HTMLDivElement>(null) const previewScrollRef = useRef<HTMLDivElement>(null)
const threadComposeAnchorRef = useRef<HTMLDivElement>(null)
const scrollThreadComposeIntoView = useCallback(() => { const scrollThreadComposeIntoView = useCallback(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
threadComposeFooterRef.current?.scrollIntoView({ threadComposeAnchorRef.current?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "end", block: "end",
inline: "nearest", inline: "nearest",
@ -821,31 +840,37 @@ export function EmailView({
}) })
}, []) }, [])
const openThreadCompose = useCallback(
(preset: ComposeOpenPreset) => {
const resolved = withTouchFullscreenComposePreset(preset)
openComposeWithInitial(resolved)
if (resolved.placement === "inline") {
scrollThreadComposeIntoView()
}
},
[openComposeWithInitial, scrollThreadComposeIntoView]
)
useEffect(() => { useEffect(() => {
if (!savedThreadDraft || hasInlineForThread) return if (!savedThreadDraft || hasInlineForThread) return
openComposeWithInitial(savedThreadDraftToComposePreset(savedThreadDraft)) openThreadCompose(savedThreadDraftToComposePreset(savedThreadDraft))
scrollThreadComposeIntoView()
}, [ }, [
email.id, email.id,
savedThreadDraft, savedThreadDraft,
hasInlineForThread, hasInlineForThread,
openComposeWithInitial, openThreadCompose,
scrollThreadComposeIntoView,
]) ])
const startThreadCompose = useCallback( const startThreadCompose = useCallback(
(kind: ThreadComposeKind) => { (kind: ThreadComposeKind) => {
openComposeWithInitial(buildThreadComposePreset(email, kind)) openThreadCompose(buildThreadComposePreset(email, kind))
scrollThreadComposeIntoView()
}, },
[email, openComposeWithInitial, scrollThreadComposeIntoView] [email, openThreadCompose]
) )
const selfIdentity = DEFAULT_IDENTITIES[0] const selfIdentity = DEFAULT_IDENTITIES[0]
const selfName = cleanSenderName(selfIdentity.name) const selfName = cleanSenderName(selfIdentity.name)
const showReplyForwardBar = !inlineCompose
const calendarInvitation = useMemo( const calendarInvitation = useMemo(
() => resolveParsedCalendarInvitation(email), () => resolveParsedCalendarInvitation(email),
[email] [email]
@ -853,7 +878,8 @@ export function EmailView({
return ( return (
<TooltipProvider delayDuration={400}> <TooltipProvider delayDuration={400}>
<div className="flex min-w-0 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div ref={previewScrollRef} className={EMAIL_PREVIEW_SCROLL_CLASS}>
{/* Subject header */} {/* Subject header */}
<div className="flex items-start gap-3 px-6 py-4"> <div className="flex items-start gap-3 px-6 py-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@ -968,10 +994,13 @@ export function EmailView({
onPrintConversation={() => openConversationPrint(email)} onPrintConversation={() => openConversationPrint(email)}
/> />
{/* Réponse / transfert : flux normal, juste sous le dernier message */} {showReplyForwardBar ? (
<div ref={threadComposeFooterRef} className="min-w-0 shrink-0"> <div
{showReplyForwardBar ? ( className={cn(
<div className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px]"> "sticky bottom-0 z-10 mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px]",
REPLY_BAR_SURFACE_CLASS
)}
>
<button <button
type="button" type="button"
onClick={() => startThreadCompose("reply")} onClick={() => startThreadCompose("reply")}
@ -996,18 +1025,11 @@ export function EmailView({
<Forward className="h-[18px] w-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} /> <Forward className="h-[18px] w-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Transférer Transférer
</button> </button>
<button
type="button"
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-[#dadce0] bg-white text-[#5f6368] shadow-sm transition-shadow hover:bg-[#f6f9fe] hover:shadow-md"
aria-label="Réaction"
>
<Smile className="h-[18px] w-[18px]" strokeWidth={1.5} />
</button>
</div> </div>
) : null} ) : null}
{inlineCompose ? ( {inlineCompose ? (
<div className="px-4 pb-6 pt-2"> <div ref={threadComposeAnchorRef} className="mt-6 px-4 pb-6 pl-[68px]">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
@ -1025,7 +1047,8 @@ export function EmailView({
</div> </div>
</div> </div>
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
</TooltipProvider> </TooltipProvider>

View File

@ -3,12 +3,12 @@
import { useState, useRef, useEffect } from "react" import { useState, useRef, useEffect } from "react"
import { Icon, addCollection } from "@iconify/react" import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi" import { icons as mdiIcons } from "@iconify-json/mdi"
import { Menu, Search, SlidersHorizontal, Pencil } from "lucide-react" import { Menu, Search, Pencil } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
addCollection(mdiIcons) addCollection(mdiIcons)
import { Input } from "@/components/ui/input"
import { UltiMailLogo } from "@/components/ultimail-logo" import { UltiMailLogo } from "@/components/ultimail-logo"
import { MailSearchBar } from "@/components/gmail/mail-search-bar"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface HeaderProps { interface HeaderProps {
@ -16,6 +16,8 @@ interface HeaderProps {
/** Match `<main>` horizontal offset (same width as sidebar rail spacer). */ /** 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
} }
const googleApps = [ const googleApps = [
@ -36,6 +38,7 @@ export function Header({
onToggleSidebar, onToggleSidebar,
sidebarCollapsed, sidebarCollapsed,
isXs = false, isXs = false,
hideSearch = false,
}: HeaderProps) { }: HeaderProps) {
const [appsMenuOpen, setAppsMenuOpen] = useState(false) const [appsMenuOpen, setAppsMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
@ -82,21 +85,13 @@ export function Header({
> >
<Search className="size-5 shrink-0 ml-0.5" /> <Search className="size-5 shrink-0 ml-0.5" />
</Button> </Button>
<div className="hidden min-w-0 flex-1 max-w-3xl sm:flex"> {!hideSearch ? (
<div className="relative flex w-full items-center"> <div className="hidden min-w-0 flex-1 max-w-3xl sm:flex">
<div className="absolute left-3 flex items-center text-gray-500"> <MailSearchBar />
<Search className="size-5 shrink-0 ml-0.5" />
</div>
<Input
type="text"
placeholder="Rechercher dans les messages"
className="h-12 w-full rounded-full border-0 bg-[#eaf1fb] pl-12 pr-12 text-sm focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:bg-white"
/>
<Button variant="ghost" size="icon" className="absolute right-2 text-gray-600">
<SlidersHorizontal className="h-5 w-5" />
</Button>
</div> </div>
</div> ) : (
<div className="hidden min-w-0 flex-1 sm:block" aria-hidden />
)}
<div className="ml-auto flex shrink-0 items-center gap-1 pl-4"> <div className="ml-auto flex shrink-0 items-center gap-1 pl-4">
{sidebarCollapsed && ( {sidebarCollapsed && (

View File

@ -0,0 +1,20 @@
"use client"
import { useEffect, useState } from "react"
import { formatInvitationTimeChip } from "@/lib/calendar-invitation"
type InvitationTimeChipTextProps = {
start: Date
end: Date
}
/** Horaire invitation formaté côté client (fuseau navigateur, évite mismatch SSR). */
export function InvitationTimeChipText({ start, end }: InvitationTimeChipTextProps) {
const [text, setText] = useState("\u00a0")
useEffect(() => {
setText(formatInvitationTimeChip(start, end))
}, [start, end])
return <span suppressHydrationWarning>{text}</span>
}

View File

@ -0,0 +1,43 @@
"use client"
import { Search, SlidersHorizontal } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
interface MailSearchBarProps {
className?: string
/** Split-pane column: balanced icon inset inside the pill. */
compact?: boolean
}
export function MailSearchBar({ className, compact = false }: MailSearchBarProps) {
return (
<div className={cn("relative flex w-full min-w-0 items-center", className)}>
<div
className={cn(
"pointer-events-none absolute flex items-center text-gray-500",
compact ? "left-4" : "left-3.5"
)}
>
<Search className="size-5 shrink-0" />
</div>
<Input
type="text"
placeholder="Rechercher dans les messages"
className={cn(
"h-12 w-full rounded-full border-0 bg-[#eaf1fb] text-sm focus-visible:bg-white focus-visible:ring-1 focus-visible:ring-blue-500",
compact ? "pl-11 pr-11" : "pl-11 pr-12"
)}
/>
<Button
variant="ghost"
size="icon"
className={cn("absolute text-gray-600", compact ? "right-3" : "right-2")}
aria-label="Filtres de recherche"
>
<SlidersHorizontal className="h-5 w-5" />
</Button>
</div>
)
}

View File

@ -0,0 +1,161 @@
"use client"
import type { ReactNode } from "react"
import { Check } from "lucide-react"
import {
Sheet,
SheetClose,
SheetContent,
SheetTitle,
} from "@/components/ui/sheet"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const sheetContentClass =
"max-h-[min(85vh,560px)] gap-0 overflow-hidden rounded-t-2xl border-[#dadce0] px-0 pb-[max(1rem,env(safe-area-inset-bottom))] pt-0 select-none left-1/2 right-auto w-[calc(100%-2rem)] max-w-md -translate-x-1/2 sm:max-w-lg"
export function SidebarNavSheetAction({
children,
onClick,
destructive,
}: {
children: ReactNode
onClick: () => void
destructive?: boolean
}) {
return (
<button
type="button"
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm transition-colors active:bg-[#e8eaed]",
destructive
? "text-red-600 hover:bg-red-50 active:bg-red-100"
: "text-[#3c4043] hover:bg-[#f1f3f4]"
)}
onClick={onClick}
>
{children}
</button>
)
}
export function SidebarNavSheetSectionLabel({ children }: { children: ReactNode }) {
return (
<div className="px-4 py-1.5 text-[11px] font-medium uppercase tracking-wide text-[#5f6368]">
{children}
</div>
)
}
export function SidebarNavSheetDivider() {
return <div className="mx-4 border-b border-[#eceff1]" />
}
export function SidebarNavSheetCheckOption({
checked,
onPick,
children,
}: {
checked: boolean
onPick: () => void
children: ReactNode
}) {
return (
<button
type="button"
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm text-[#3c4043] transition-colors hover:bg-[#f1f3f4] active:bg-[#e8eaed]"
onClick={onPick}
>
<span className="min-w-0 flex-1">{children}</span>
<span className="flex size-4 shrink-0 items-center justify-center" aria-hidden={!checked}>
{checked ? <Check className="size-4 text-gray-900" strokeWidth={2} /> : null}
</span>
</button>
)
}
export function SidebarNavSheetColorPicker({
title,
dotClass,
swatches,
onPick,
}: {
title: string
dotClass: string
swatches: readonly string[]
onPick: (swatch: string) => void
}) {
return (
<div className="px-4 py-2">
<div className="mb-2 flex items-center gap-2 text-sm text-[#3c4043]">
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white">
<span
className={cn("block size-3 rounded-sm border border-black/10", dotClass)}
aria-hidden
/>
</span>
{title}
</div>
<div className="grid grid-cols-6 gap-1.5">
{swatches.map((sw) => (
<button
key={sw}
type="button"
title={sw}
onClick={() => onPick(sw)}
className={cn(
"size-8 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2 hover:ring-gray-400 focus-visible:ring-2 focus-visible:ring-gray-500",
sw
)}
/>
))}
</div>
</div>
)
}
export function SidebarNavOptionsSheet({
open,
onOpenChange,
title,
colorDotClass,
children,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
colorDotClass?: string
children: ReactNode
}) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" hideClose className={sheetContentClass}>
<div className="relative flex items-center gap-3 border-b border-[#eceff1] px-4 py-3 pr-12">
{colorDotClass ? (
<span
className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white"
aria-hidden
>
<span
className={cn(
"block size-3 rounded-sm border border-black/10",
colorDotClass
)}
/>
</span>
) : null}
<SheetTitle className="min-w-0 flex-1 truncate text-left text-base font-medium leading-5 text-[#3c4043]">
{title}
</SheetTitle>
<SheetClose
className="absolute right-4 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-xs text-[#5f6368] opacity-80 outline-none transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Fermer"
>
<XIcon className="size-4" />
</SheetClose>
</div>
<div className="flex flex-col overflow-y-auto py-1">{children}</div>
</SheetContent>
</Sheet>
)
}

View File

@ -25,7 +25,8 @@ import {
Trash2, Trash2,
} from "lucide-react" } from "lucide-react"
import { cn, formatCount } from "@/lib/utils" import { cn, formatCount } from "@/lib/utils"
import { readXsMatches } from "@/hooks/use-xs" import { useIsXs } from "@/hooks/use-xs"
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import { import {
useState, useState,
useRef, useRef,
@ -71,6 +72,15 @@ import { Button } from "@/components/ui/button"
import { Icon, addCollection } from "@iconify/react" import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi" import { icons as mdiIcons } from "@iconify-json/mdi"
import { UltiMailLogo } from "@/components/ultimail-logo" import { UltiMailLogo } from "@/components/ultimail-logo"
import {
SidebarNavOptionsSheet,
SidebarNavSheetAction,
SidebarNavSheetCheckOption,
SidebarNavSheetColorPicker,
SidebarNavSheetDivider,
SidebarNavSheetSectionLabel,
} from "@/components/gmail/sidebar-nav-options-sheet"
import { useSidebarTouchOptionsMenu } from "@/components/gmail/use-sidebar-touch-options"
addCollection(mdiIcons) addCollection(mdiIcons)
import { import {
@ -119,6 +129,8 @@ interface SidebarProps {
collapsed: boolean collapsed: boolean
/** Nombre de messages non lus par id de ligne (boîte, catégorie, dossier, libellé). */ /** Nombre de messages non lus par id de ligne (boîte, catégorie, dossier, libellé). */
folderUnreadCounts?: Record<string, number> folderUnreadCounts?: Record<string, number>
/** md+ split pane: mobile-style branding, no header compose. */
splitView?: boolean
} }
const mainItems = [ const mainItems = [
@ -311,7 +323,7 @@ function SidebarNavDragHandle({
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
className="flex h-8 w-4 shrink-0 cursor-grab items-center justify-center text-gray-400 opacity-50 transition-opacity hover:opacity-100 active:cursor-grabbing group-hover/folderrow:opacity-100 group-hover/labelrow:opacity-100" className="pointer-events-none absolute left-0 top-1/2 z-10 flex h-8 w-4 -translate-y-1/2 cursor-grab items-center justify-center text-gray-400 opacity-0 transition-opacity hover:opacity-100 active:cursor-grabbing group-hover/folderrow:pointer-events-auto group-hover/folderrow:opacity-100 group-hover/labelrow:pointer-events-auto group-hover/labelrow:opacity-100"
> >
<GripVertical className="h-3.5 w-3.5" aria-hidden /> <GripVertical className="h-3.5 w-3.5" aria-hidden />
</span> </span>
@ -326,6 +338,7 @@ function SidebarOverflowColumn({
isSelected, isSelected,
hasUnread, hasUnread,
className, className,
showMenuButton = true,
children, children,
}: { }: {
unread: number unread: number
@ -334,8 +347,26 @@ function SidebarOverflowColumn({
isSelected?: boolean isSelected?: boolean
hasUnread?: boolean hasUnread?: boolean
className?: string className?: string
children: ReactNode showMenuButton?: boolean
children?: ReactNode
}) { }) {
if (!showMenuButton) {
if (unread <= 0) return null
return (
<div className={cn("relative h-8 w-8 shrink-0", className)}>
<span
className={cn(
"flex h-full items-center justify-center text-xs tabular-nums leading-none",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold"
)}
>
{formatCount(unread)}
</span>
</div>
)
}
const countHoverHide = `group-hover/${hoverGroup}:opacity-0` const countHoverHide = `group-hover/${hoverGroup}:opacity-0`
const menuHoverShow = `group-hover/${hoverGroup}:opacity-100 [&:has(button:focus-visible)]:opacity-100` const menuHoverShow = `group-hover/${hoverGroup}:opacity-100 [&:has(button:focus-visible)]:opacity-100`
@ -376,6 +407,7 @@ function CategoryNavRow({
onSelectFolder, onSelectFolder,
onDisableNavLabel, onDisableNavLabel,
onEnableNavLabel, onEnableNavLabel,
touchNav,
variant = "listed", variant = "listed",
}: { }: {
item: CategoryNavSourceItem item: CategoryNavSourceItem
@ -385,6 +417,7 @@ function CategoryNavRow({
onSelectFolder: (id: string) => void onSelectFolder: (id: string) => void
onDisableNavLabel: (id: string) => void onDisableNavLabel: (id: string) => void
onEnableNavLabel: (id: string) => void onEnableNavLabel: (id: string) => void
touchNav: boolean
variant?: "listed" | "hidden" variant?: "listed" | "hidden"
}) { }) {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label) const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
@ -393,6 +426,9 @@ function CategoryNavRow({
const isHiddenRow = variant === "hidden" const isHiddenRow = variant === "hidden"
const showCategoryMenu = isSystemNavLabelId(item.id) && isExpanded const showCategoryMenu = isSystemNavLabelId(item.id) && isExpanded
const hasUnread = unreadCount > 0 const hasUnread = unreadCount > 0
const touchMenuEnabled = touchNav && (isHiddenRow || showCategoryMenu)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchMenuEnabled)
const handleMenuOpenChange = (open: boolean) => { const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open) setMenuOpen(open)
@ -424,71 +460,95 @@ function CategoryNavRow({
if (isHiddenRow) { if (isHiddenRow) {
return ( return (
<div <>
{...dropHandlers} <div
className={cn( {...dropHandlers}
"flex h-8 w-full min-w-0 shrink-0 items-center pl-6 pr-2 text-gray-500 transition-colors", {...touchRowProps}
isOver ? "rounded-r-full" : "rounded-r-none", className={cn(
isOver && "bg-yellow-100 text-gray-900" "flex h-8 w-full min-w-0 shrink-0 items-center pl-6 pr-2 text-gray-500 transition-colors",
)} isOver ? "rounded-r-full" : "rounded-r-none",
> isOver && "bg-yellow-100 text-gray-900",
<button touchRowClassName
type="button" )}
onClick={() => onSelectFolder(item.id)}
className="flex h-8 min-w-0 flex-1 items-center gap-4 rounded-r-none py-0 pr-1 text-left outline-none hover:rounded-r-full hover:bg-gray-50"
> >
{rowIcon} <button
<div className="flex min-w-0 flex-1 items-baseline gap-4"> type="button"
<span onClick={() => onSelectFolder(item.id)}
className={cn( className="flex h-8 min-w-0 flex-1 items-center gap-4 rounded-r-none py-0 pr-1 text-left outline-none hover:rounded-r-full hover:bg-gray-50"
"min-w-0 flex-1 truncate text-sm leading-5", >
hasUnread && "font-semibold text-gray-900" {rowIcon}
)} <div className="flex min-w-0 flex-1 items-baseline gap-4">
>
{item.label}
</span>
{unreadCount > 0 && (
<span <span
className={cn( className={cn(
"shrink-0 text-xs tabular-nums leading-none text-gray-700", "min-w-0 flex-1 truncate text-sm leading-5",
hasUnread && "font-semibold" hasUnread && "font-semibold text-gray-900"
)} )}
> >
{formatCount(unreadCount)} {item.label}
</span> </span>
)} {unreadCount > 0 && (
</div> <span
</button> className={cn(
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}> "shrink-0 text-xs tabular-nums leading-none text-gray-700",
<DropdownMenuTrigger asChild> hasUnread && "font-semibold"
<button )}
ref={menuTriggerRef} >
type="button" {formatCount(unreadCount)}
className={sidebarOverflowMenuButtonClass} </span>
aria-label={`Options pour ${item.label}`} )}
onClick={(e) => e.stopPropagation()} </div>
> </button>
<MoreVertical className="h-4 w-4" /> {!touchNav && (
</button> <DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
</DropdownMenuTrigger> <DropdownMenuTrigger asChild>
<DropdownMenuContent align="end" className="min-w-40"> <button
<DropdownMenuItem ref={menuTriggerRef}
type="button"
className={sidebarOverflowMenuButtonClass}
aria-label={`Options pour ${item.label}`}
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem
onClick={() => {
onEnableNavLabel(item.id)
setMenuOpen(false)
}}
>
Réactiver le libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{touchNav && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
>
<SidebarNavSheetAction
onClick={() => { onClick={() => {
onEnableNavLabel(item.id) onEnableNavLabel(item.id)
setMenuOpen(false) closeSheet()
}} }}
> >
Réactiver le libellé Réactiver le libellé
</DropdownMenuItem> </SidebarNavSheetAction>
</DropdownMenuContent> </SidebarNavOptionsSheet>
</DropdownMenu> )}
</div> </>
) )
} }
return ( return (
<>
<div <div
{...dropHandlers} {...dropHandlers}
{...touchRowProps}
className={cn( className={cn(
"group/catnav flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center pl-6 pr-2 transition-colors", "group/catnav flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center pl-6 pr-2 transition-colors",
navRowRoundedWhenActive(isSelected || isOver), navRowRoundedWhenActive(isSelected || isOver),
@ -498,7 +558,8 @@ function CategoryNavRow({
? "bg-yellow-100 text-gray-900" ? "bg-yellow-100 text-gray-900"
: hasUnread : hasUnread
? "text-gray-900 hover:bg-gray-100" ? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100" : "text-gray-700 hover:bg-gray-100",
touchRowClassName
)} )}
> >
<button <button
@ -538,42 +599,63 @@ function CategoryNavRow({
{showCategoryMenu && ( {showCategoryMenu && (
<SidebarOverflowColumn <SidebarOverflowColumn
unread={unreadCount} unread={unreadCount}
menuOpen={menuOpen} menuOpen={menuOpen || sheetOpen}
hoverGroup="catnav" hoverGroup="catnav"
isSelected={isSelected} isSelected={isSelected}
hasUnread={hasUnread} hasUnread={hasUnread}
showMenuButton={!touchNav}
> >
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}> {!touchNav && (
<DropdownMenuTrigger asChild> <DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<button <DropdownMenuTrigger asChild>
ref={menuTriggerRef} <button
type="button" ref={menuTriggerRef}
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")} type="button"
aria-label={`Options pour ${item.label}`} className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
onClick={(e) => { aria-label={`Options pour ${item.label}`}
e.stopPropagation() onClick={(e) => {
}} e.stopPropagation()
> }}
<MoreVertical className="h-4 w-4" /> >
</button> <MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger> </button>
<DropdownMenuContent align="end" className="min-w-40"> </DropdownMenuTrigger>
<DropdownMenuItem disabled className="text-gray-400"> <DropdownMenuContent align="end" className="min-w-40">
Afficher <DropdownMenuItem disabled className="text-gray-400">
</DropdownMenuItem> Afficher
<DropdownMenuItem </DropdownMenuItem>
onClick={() => { <DropdownMenuItem
onDisableNavLabel(item.id) onClick={() => {
setMenuOpen(false) onDisableNavLabel(item.id)
}} setMenuOpen(false)
> }}
Désactiver le libellé >
</DropdownMenuItem> Désactiver le libellé
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> </DropdownMenuContent>
</DropdownMenu>
)}
</SidebarOverflowColumn> </SidebarOverflowColumn>
)} )}
</div> </div>
{touchNav && showCategoryMenu && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
>
<div className="px-4 py-3 text-sm text-[#9aa0a6]">Afficher</div>
<SidebarNavSheetAction
onClick={() => {
onDisableNavLabel(item.id)
closeSheet()
}}
>
Désactiver le libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)}
</>
) )
} }
@ -582,6 +664,7 @@ export function Sidebar({
onSelectFolder, onSelectFolder,
collapsed, collapsed,
folderUnreadCounts = {}, folderUnreadCounts = {},
splitView = false,
}: SidebarProps) { }: SidebarProps) {
const { openCompose } = useComposeActions() const { openCompose } = useComposeActions()
const [hoverExpanded, setHoverExpanded] = useState(false) const [hoverExpanded, setHoverExpanded] = useState(false)
@ -589,8 +672,11 @@ export function Sidebar({
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set()) const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null) const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const sidebarRef = useRef<HTMLElement>(null) const sidebarRef = useRef<HTMLElement>(null)
const touchNav = useTouchNav()
const isXs = useIsXs()
const isExpanded = !collapsed || hoverExpanded const isExpanded = !collapsed || (!touchNav && hoverExpanded)
const isOverlayOpen = touchNav && !collapsed
const { const {
folderTree, folderTree,
@ -806,7 +892,7 @@ export function Sidebar({
}, [selectedFolder]) }, [selectedFolder])
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (readXsMatches()) return if (readTouchNavMatches()) return
if (collapsed) { if (collapsed) {
hoverTimeoutRef.current = setTimeout(() => { hoverTimeoutRef.current = setTimeout(() => {
setHoverExpanded(true) setHoverExpanded(true)
@ -819,10 +905,14 @@ export function Sidebar({
clearTimeout(hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null hoverTimeoutRef.current = null
} }
if (readXsMatches()) return if (readTouchNavMatches()) return
setHoverExpanded(false) setHoverExpanded(false)
} }
useEffect(() => {
if (touchNav) setHoverExpanded(false)
}, [touchNav, collapsed])
useEffect(() => { useEffect(() => {
return () => { return () => {
if (hoverTimeoutRef.current) { if (hoverTimeoutRef.current) {
@ -834,6 +924,10 @@ export function Sidebar({
/** Inset rows from sidebar right edge (padding works with w-full; margin-right often clips under overflow-x-hidden). */ /** Inset rows from sidebar right edge (padding works with w-full; margin-right often clips under overflow-x-hidden). */
const navRailInset = "pr-3.5" const navRailInset = "pr-3.5"
/** pl-6 + demi-largeur icône nav (h-5) → axe à 34px ; picto split (size-9) centré sur cet axe. */
const splitViewLogoIconClass = "size-9 shrink-0"
const splitViewLogoHeaderClass = "min-h-10 pl-4 pr-3.5 pb-2"
/** Same row geometry collapsed / expanded / hover so icons never jump (h-8, pl-6 icon column). */ /** Same row geometry collapsed / expanded / hover so icons never jump (h-8, pl-6 icon column). */
const NavItem = ({ const NavItem = ({
item, item,
@ -923,6 +1017,8 @@ export function Sidebar({
const [subfolderName, setSubfolderName] = useState("") const [subfolderName, setSubfolderName] = useState("")
const folderRenameInputRef = useRef<HTMLInputElement>(null) const folderRenameInputRef = useRef<HTMLInputElement>(null)
const subfolderNameInputRef = useRef<HTMLInputElement>(null) const subfolderNameInputRef = useRef<HTMLInputElement>(null)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchNav && isExpanded)
useEffect(() => { useEffect(() => {
setRenameDraft(node.label) setRenameDraft(node.label)
@ -936,7 +1032,7 @@ export function Sidebar({
} }
const rowHoverHeld = const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen) !isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
const prefs = getNavItemPrefs(node.id) const prefs = getNavItemPrefs(node.id)
const moveTargets = useMemo( const moveTargets = useMemo(
@ -998,14 +1094,15 @@ export function Sidebar({
} }
const rowClass = cn( const rowClass = cn(
"group/folderrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm transition-colors", "group/folderrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm transition-colors",
isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none", isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
isStickyBranch && "sticky border-b border-gray-200/70", isStickyBranch && "sticky border-b border-gray-200/70",
isStickyBranch && !isSelected && !rowHoverHeld && "bg-app-canvas", isStickyBranch && !isSelected && !rowHoverHeld && "bg-app-canvas",
isSelected && "bg-[#d3e3fd] font-medium text-gray-900", isSelected && "bg-[#d3e3fd] font-medium text-gray-900",
!isSelected && hasUnread && "text-gray-900", !isSelected && hasUnread && "text-gray-900",
isOver && "bg-yellow-100 text-gray-900", isOver && "bg-yellow-100 text-gray-900",
rowHoverHeld && "bg-gray-100 text-gray-900" rowHoverHeld && "bg-gray-100 text-gray-900",
touchRowClassName
) )
const rowStyle: CSSProperties = { const rowStyle: CSSProperties = {
paddingLeft: 24 + depth * 16, paddingLeft: 24 + depth * 16,
@ -1015,12 +1112,14 @@ export function Sidebar({
const overflowMenu = ( const overflowMenu = (
<SidebarOverflowColumn <SidebarOverflowColumn
unread={unread} unread={unread}
menuOpen={menuOpen} menuOpen={menuOpen || sheetOpen}
hoverGroup="folderrow" hoverGroup="folderrow"
isSelected={isSelected} isSelected={isSelected}
hasUnread={hasUnread} hasUnread={hasUnread}
className={cn(!isExpanded && "hidden", "mr-[-11px]")} className={cn(!isExpanded && "hidden", "mr-[-11px]")}
showMenuButton={!touchNav}
> >
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}> <DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
@ -1116,9 +1215,115 @@ export function Sidebar({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
</SidebarOverflowColumn> </SidebarOverflowColumn>
) )
const folderOptionsSheet = touchNav && isExpanded && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={node.label}
colorDotClass={dotClass}
>
<SidebarNavSheetColorPicker
title="Couleur du dossier"
dotClass={dotClass}
swatches={LABEL_MENU_COLOR_SWATCHES}
onPick={(sw) => {
updateFolderOrLabelColor(node.id, sw)
closeSheet()
}}
/>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des dossiers</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "show"}
onPick={() => {
setNavItemSidebarVisibility(node.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "showUnread"}
onPick={() => {
setNavItemSidebarVisibility(node.id, "showUnread")
closeSheet()
}}
>
Afficher si messages non lus
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "hide"}
onPick={() => {
setNavItemSidebarVisibility(node.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des messages</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.messages === "show"}
onPick={() => {
setNavItemMessageVisibility(node.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.messages === "hide"}
onPick={() => {
setNavItemMessageVisibility(node.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetAction
onClick={() => {
setRenameDraft(node.label)
setRenameOpen(true)
closeSheet()
}}
>
Renommer
</SidebarNavSheetAction>
<SidebarNavSheetAction
onClick={() => {
setMoveParent("__root__")
setMoveOpen(true)
closeSheet()
}}
>
Déplacer
</SidebarNavSheetAction>
<SidebarNavSheetAction
onClick={() => {
setSubfolderName("")
setSubfolderOpen(true)
closeSheet()
}}
>
Nouveau sous-dossier
</SidebarNavSheetAction>
<SidebarNavSheetAction
destructive
onClick={() => {
removeFolderOrLabelRow(node.id)
closeSheet()
}}
>
Supprimer le dossier
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)
const onFolderRowDragEnter = (e: React.DragEvent) => { const onFolderRowDragEnter = (e: React.DragEvent) => {
const active = navDragRef.current const active = navDragRef.current
if (active?.kind === "folder" && active.id !== node.id) { if (active?.kind === "folder" && active.id !== node.id) {
@ -1175,12 +1380,10 @@ export function Sidebar({
beginNavDrag(payload, rowEl) beginNavDrag(payload, rowEl)
} }
return ( const folderRowEl = (
<>
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>
<div <div
data-nav-row data-nav-row
{...touchRowProps}
onDragEnter={onFolderRowDragEnter} onDragEnter={onFolderRowDragEnter}
onDragOver={onFolderRowDragOver} onDragOver={onFolderRowDragOver}
onDragLeave={onFolderRowDragLeave} onDragLeave={onFolderRowDragLeave}
@ -1263,7 +1466,15 @@ export function Sidebar({
</div> </div>
{overflowMenu} {overflowMenu}
</div> </div>
</ContextMenuTrigger> )
return (
<>
{touchNav ? (
folderRowEl
) : (
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>{folderRowEl}</ContextMenuTrigger>
<ContextMenuContent className={folderMenuSurface}> <ContextMenuContent className={folderMenuSurface}>
{colorSub("context")} {colorSub("context")}
<ContextMenuSeparator className="my-1.5 bg-gray-200" /> <ContextMenuSeparator className="my-1.5 bg-gray-200" />
@ -1341,6 +1552,8 @@ export function Sidebar({
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
)}
{folderOptionsSheet}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}> <Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent <DialogContent
@ -1574,6 +1787,8 @@ export function Sidebar({
const labelRenameInputRef = useRef<HTMLInputElement>(null) const labelRenameInputRef = useRef<HTMLInputElement>(null)
const sublabelNameInputRef = useRef<HTMLInputElement>(null) const sublabelNameInputRef = useRef<HTMLInputElement>(null)
const canDragLabel = labelRowExpanded && !isSystemNavLabelId(item.id) const canDragLabel = labelRowExpanded && !isSystemNavLabelId(item.id)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchNav && labelRowExpanded)
useEffect(() => { useEffect(() => {
setRenameDraft(item.label) setRenameDraft(item.label)
@ -1587,7 +1802,7 @@ export function Sidebar({
} }
const rowHoverHeld = const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen) !isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
const prefs = getNavItemPrefs(item.id) const prefs = getNavItemPrefs(item.id)
const labelDotClass = item.color ?? "bg-gray-400" const labelDotClass = item.color ?? "bg-gray-400"
@ -1643,7 +1858,7 @@ export function Sidebar({
} }
const rowClass = cn( const rowClass = cn(
"group/labelrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors", "group/labelrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors",
navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld), navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
isSelected isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium" ? "bg-[#d3e3fd] text-gray-900 font-medium"
@ -1653,7 +1868,8 @@ export function Sidebar({
? "bg-gray-100 text-gray-900" ? "bg-gray-100 text-gray-900"
: hasUnread : hasUnread
? "text-gray-900 hover:bg-gray-100" ? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100" : "text-gray-700 hover:bg-gray-100",
touchRowClassName
) )
const onLabelRowDragEnter = (e: React.DragEvent) => { const onLabelRowDragEnter = (e: React.DragEvent) => {
@ -1717,12 +1933,14 @@ export function Sidebar({
const overflowMenu = labelRowExpanded ? ( const overflowMenu = labelRowExpanded ? (
<SidebarOverflowColumn <SidebarOverflowColumn
unread={unreadCount} unread={unreadCount}
menuOpen={menuOpen} menuOpen={menuOpen || sheetOpen}
hoverGroup="labelrow" hoverGroup="labelrow"
isSelected={isSelected} isSelected={isSelected}
hasUnread={hasUnread} hasUnread={hasUnread}
className="mr-[-7px]" className="mr-[-7px]"
showMenuButton={!touchNav}
> >
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}> <DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
@ -1809,15 +2027,110 @@ export function Sidebar({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
</SidebarOverflowColumn> </SidebarOverflowColumn>
) : null ) : null
return ( const labelOptionsSheet = touchNav && labelRowExpanded && (
<> <SidebarNavOptionsSheet
<ContextMenu onOpenChange={setContextMenuOpen}> open={sheetOpen}
<ContextMenuTrigger asChild> onOpenChange={setSheetOpen}
title={item.label}
colorDotClass={labelDotClass}
>
<SidebarNavSheetColorPicker
title="Couleur du libellé"
dotClass={labelDotClass}
swatches={LABEL_MENU_COLOR_SWATCHES}
onPick={(sw) => {
updateFolderOrLabelColor(item.id, sw)
closeSheet()
}}
/>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des libellés</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "show"}
onPick={() => {
setNavItemSidebarVisibility(item.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "showUnread"}
onPick={() => {
setNavItemSidebarVisibility(item.id, "showUnread")
closeSheet()
}}
>
Afficher si messages non lus
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "hide"}
onPick={() => {
setNavItemSidebarVisibility(item.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des messages</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.messages === "show"}
onPick={() => {
setNavItemMessageVisibility(item.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.messages === "hide"}
onPick={() => {
setNavItemMessageVisibility(item.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetAction
onClick={() => {
setRenameDraft(item.label)
setRenameOpen(true)
closeSheet()
}}
>
Renommer
</SidebarNavSheetAction>
<SidebarNavSheetAction
destructive
onClick={() => {
removeFolderOrLabelRow(item.id)
closeSheet()
}}
>
Supprimer le libellé
</SidebarNavSheetAction>
<SidebarNavSheetAction
onClick={() => {
setSublabelName("")
setSublabelOpen(true)
closeSheet()
}}
>
Ajouter un sous-libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)
const labelRowEl = (
<div <div
data-nav-row data-nav-row
{...touchRowProps}
onDragEnter={onLabelRowDragEnter} onDragEnter={onLabelRowDragEnter}
onDragOver={onLabelRowDragOver} onDragOver={onLabelRowDragOver}
onDragLeave={onLabelRowDragLeave} onDragLeave={onLabelRowDragLeave}
@ -1860,7 +2173,15 @@ export function Sidebar({
</div> </div>
{overflowMenu} {overflowMenu}
</div> </div>
</ContextMenuTrigger> )
return (
<>
{touchNav ? (
labelRowEl
) : (
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>{labelRowEl}</ContextMenuTrigger>
<ContextMenuContent className={labelMenuSurface}> <ContextMenuContent className={labelMenuSurface}>
{colorSub("context")} {colorSub("context")}
<ContextMenuSeparator className="my-1.5 bg-gray-200" /> <ContextMenuSeparator className="my-1.5 bg-gray-200" />
@ -1929,6 +2250,8 @@ export function Sidebar({
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
)}
{labelOptionsSheet}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}> <Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent <DialogContent
@ -2029,27 +2352,59 @@ export function Sidebar({
return ( return (
<aside <aside
ref={sidebarRef} ref={sidebarRef}
data-sidebar
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
className={cn( className={cn(
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden bg-app-canvas transition-[width,transform] duration-200 z-40", "absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden bg-app-canvas transition-[width,transform] duration-200 z-40 select-none",
isExpanded ? "w-60" : "w-[68px]", isExpanded ? "w-60" : "w-[68px]",
hoverExpanded && "shadow-xl border-r border-gray-200", !touchNav && hoverExpanded && "shadow-xl border-r border-gray-200",
!collapsed && "max-sm:z-50 max-sm:shadow-xl max-sm:border-r max-sm:border-gray-200", isOverlayOpen && "z-50 shadow-xl border-r border-gray-200",
collapsed && "max-sm:-translate-x-full max-sm:pointer-events-none" collapsed && isXs && "-translate-x-full pointer-events-none"
)} )}
> >
<div className="flex shrink-0 items-center justify-between px-4 pt-4 pb-4 sm:hidden"> <div
<UltiMailLogo className="min-h-8" /> className={cn(
<Button variant="ghost" size="icon" className="size-9 shrink-0 text-gray-600" aria-label="Réglages"> "flex shrink-0 items-center bg-app-canvas",
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden /> splitView
</Button> ? cn(
splitViewLogoHeaderClass,
isExpanded ? "justify-between" : "justify-start"
)
: "justify-between px-4 pt-4 pb-4 sm:hidden"
)}
>
{splitView && !isExpanded ? (
<UltiMailLogo variant="mark" className={splitViewLogoIconClass} />
) : (
<>
<UltiMailLogo
className={cn(
"shrink-0",
splitView
? "max-w-[140px] gap-4 [&_img]:size-9"
: "min-h-8"
)}
/>
{(splitView || touchNav) && isExpanded && (
<Button
variant="ghost"
size="icon"
className="size-9 shrink-0 text-gray-600"
aria-label="Réglages"
>
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
</Button>
)}
</>
)}
</div> </div>
<div <div
className={cn( className={cn(
"hidden shrink-0 bg-app-canvas z-10 pt-1 pb-3 pl-2 sm:flex", "hidden shrink-0 bg-app-canvas z-10 pt-1 pb-3 pl-2 sm:flex",
isExpanded ? "pr-3.5" : "pr-2" isExpanded ? "pr-3.5" : "pr-2",
splitView && "!hidden"
)} )}
> >
<button <button
@ -2097,6 +2452,7 @@ export function Sidebar({
isExpanded={isExpanded} isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0} unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder} onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)} onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)} onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/> />
@ -2151,6 +2507,7 @@ export function Sidebar({
isExpanded={isExpanded} isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0} unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder} onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)} onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)} onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/> />
@ -2185,6 +2542,7 @@ export function Sidebar({
isExpanded={isExpanded} isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0} unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder} onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)} onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)} onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
variant="hidden" variant="hidden"
@ -2198,7 +2556,7 @@ export function Sidebar({
{/* Dossiers (hiérarchie : chevron = replier / déplier uniquement) */} {/* Dossiers (hiérarchie : chevron = replier / déplier uniquement) */}
<div className="mt-3 pt-1"> <div className="mt-3 pt-1">
<div <div
className="sticky top-0 z-31 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3" className="sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
title={!isExpanded ? "Dossiers" : undefined} title={!isExpanded ? "Dossiers" : undefined}
> >
<Folder className="h-5 w-5 shrink-0 text-gray-600" aria-hidden /> <Folder className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
@ -2234,7 +2592,7 @@ export function Sidebar({
{/* Labels */} {/* Labels */}
<div className="mt-3 pt-1"> <div className="mt-3 pt-1">
<div <div
className="sticky top-0 z-31 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3" className="sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
title={!isExpanded ? "Libellés" : undefined} title={!isExpanded ? "Libellés" : undefined}
> >
<Tag className="h-5 w-5 shrink-0 text-gray-600" aria-hidden /> <Tag className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
@ -2270,10 +2628,12 @@ export function Sidebar({
</div> </div>
{/* Sortbot */} {/* Sortbot */}
<div className={cn( <div
"z-30 mt-auto bg-app-canvas pt-2", className={cn(
"max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3" "relative z-32 mt-auto bg-app-canvas pt-2",
)}> "max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3"
)}
>
<button <button
type="button" type="button"
title={!isExpanded ? "Sortbot" : undefined} title={!isExpanded ? "Sortbot" : undefined}

View File

@ -0,0 +1,40 @@
"use client"
import { useCallback, useRef, useState } from "react"
import { useLongPress } from "@/hooks/use-long-press"
/** Long-press / context-menu handlers to open a sidebar row options bottom sheet. */
export function useSidebarTouchOptionsMenu(enabled: boolean) {
const [sheetOpen, setSheetOpen] = useState(false)
const lastOpenRef = useRef(0)
const openSheet = useCallback(() => {
const now = Date.now()
if (now - lastOpenRef.current < 400) return
lastOpenRef.current = now
setSheetOpen(true)
}, [])
const longPress = useLongPress(openSheet, { disabled: !enabled })
const touchRowProps = enabled
? {
onContextMenu: (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
openSheet()
},
onPointerDown: longPress.onPointerDown,
onPointerUp: longPress.onPointerUp,
onPointerLeave: longPress.onPointerLeave,
onPointerCancel: longPress.onPointerCancel,
onClickCapture: longPress.onClickCapture,
}
: {}
const touchRowClassName = enabled ? "select-none" : undefined
const closeSheet = useCallback(() => setSheetOpen(false), [])
return { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet }
}

52
hooks/use-long-press.ts Normal file
View File

@ -0,0 +1,52 @@
import { useCallback, useRef } from "react"
const DEFAULT_DELAY_MS = 500
export function useLongPress(
onLongPress: () => void,
options?: { delay?: number; disabled?: boolean }
) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const firedRef = useRef(false)
const delay = options?.delay ?? DEFAULT_DELAY_MS
const disabled = options?.disabled ?? false
const clear = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
if (disabled) return
if (e.pointerType === "mouse" && e.button !== 0) return
firedRef.current = false
clear()
timerRef.current = setTimeout(() => {
firedRef.current = true
onLongPress()
}, delay)
},
[clear, delay, disabled, onLongPress]
)
const onClickCapture = useCallback(
(e: React.MouseEvent) => {
if (!firedRef.current) return
e.preventDefault()
e.stopPropagation()
firedRef.current = false
},
[]
)
return {
onPointerDown,
onPointerUp: clear,
onPointerLeave: clear,
onPointerCancel: clear,
onClickCapture,
}
}

View File

@ -0,0 +1,47 @@
import { useLayoutEffect, useState } from "react"
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
/** Tailwind `md` breakpoint — split view never applies below this width. */
export const MD_MIN_PX = 768
const MD_MQ = `(min-width: ${MD_MIN_PX}px)`
const LANDSCAPE_MQ = "(orientation: landscape)"
/**
* User preference (settings UI later). When true, split view is enabled on md+
* even on non-touch desktops.
*/
export const MAIL_SPLIT_VIEW_USER_SETTING = false
export function readMailSplitViewMatches(): boolean {
if (typeof window === "undefined") return false
if (!window.matchMedia(MD_MQ).matches) return false
const coarse = readCoarsePointerMatches()
const tabletLandscape =
coarse && window.matchMedia(LANDSCAPE_MQ).matches
return tabletLandscape || MAIL_SPLIT_VIEW_USER_SETTING
}
export function useMailSplitView() {
const [splitView, setSplitView] = useState(false)
useLayoutEffect(() => {
const mqlMd = window.matchMedia(MD_MQ)
const mqlLandscape = window.matchMedia(LANDSCAPE_MQ)
const mqlCoarse = window.matchMedia(
"(hover: none) and (pointer: coarse)"
)
const update = () => setSplitView(readMailSplitViewMatches())
update()
mqlMd.addEventListener("change", update)
mqlLandscape.addEventListener("change", update)
mqlCoarse.addEventListener("change", update)
return () => {
mqlMd.removeEventListener("change", update)
mqlLandscape.removeEventListener("change", update)
mqlCoarse.removeEventListener("change", update)
}
}, [])
return splitView
}

49
hooks/use-touch-nav.ts Normal file
View File

@ -0,0 +1,49 @@
import { useLayoutEffect, useState } from "react"
import { readXsMatches, XS_MAX_PX } from "@/hooks/use-xs"
/** Primary input is touch without reliable hover (tablets, phones). */
export const COARSE_POINTER_MQ = "(hover: none) and (pointer: coarse)"
export function readCoarsePointerMatches(): boolean {
if (typeof window === "undefined") return false
return window.matchMedia(COARSE_POINTER_MQ).matches
}
/** Mobile layout or touch-first navigation (no hover peek, overlay when open). */
export function readTouchNavMatches(): boolean {
return readXsMatches() || readCoarsePointerMatches()
}
export function useTouchNav() {
const [touchNav, setTouchNav] = useState(false)
useLayoutEffect(() => {
const xsMq = `(max-width: ${XS_MAX_PX}px)`
const mqlXs = window.matchMedia(xsMq)
const mqlCoarse = window.matchMedia(COARSE_POINTER_MQ)
const update = () => setTouchNav(readTouchNavMatches())
update()
mqlXs.addEventListener("change", update)
mqlCoarse.addEventListener("change", update)
return () => {
mqlXs.removeEventListener("change", update)
mqlCoarse.removeEventListener("change", update)
}
}, [])
return touchNav
}
export function useCoarsePointer() {
const [coarse, setCoarse] = useState(false)
useLayoutEffect(() => {
const mql = window.matchMedia(COARSE_POINTER_MQ)
const update = () => setCoarse(readCoarsePointerMatches())
update()
mql.addEventListener("change", update)
return () => mql.removeEventListener("change", update)
}, [])
return coarse
}

View File

@ -261,6 +261,27 @@ export function extractEmbeddedIcsFromHtml(html: string): string | null {
) )
} }
/** Libellé UTC+X stable (évite GMT vs UTC entre Node et le navigateur). */
function formatStableUtcOffsetLabel(date: Date, timeZone: string): string {
const offset =
new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "longOffset",
})
.formatToParts(date)
.find((p) => p.type === "timeZoneName")?.value ?? ""
const normalized = offset.replace(/^GMT/i, "UTC")
const m = normalized.match(/^UTC([+-])(\d{1,2})(?::(\d{2}))?$/i)
if (!m) return normalized
const sign = m[1]
const hours = Number(m[2])
const minutes = m[3] ? Number(m[3]) : 0
if (minutes === 0) return `UTC${sign}${hours}`
return `UTC${sign}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`
}
export function formatInvitationTimeChip(start: Date, end: Date): string { export function formatInvitationTimeChip(start: Date, end: Date): string {
const locale = fr const locale = fr
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
@ -277,11 +298,7 @@ export function formatInvitationTimeChip(start: Date, end: Date): string {
: format(start, "EEE d MMM yyyy", { locale }) : format(start, "EEE d MMM yyyy", { locale })
} }
const tzShort = const tzShort = formatStableUtcOffsetLabel(start, tz)
new Intl.DateTimeFormat(undefined, { timeZone: tz, timeZoneName: "short" })
.formatToParts(start)
.find((p) => p.type === "timeZoneName")?.value ?? ""
const range = `${startLabel} ${endLabel}` const range = `${startLabel} ${endLabel}`
return tzShort ? `${day}${range} (${tzShort})` : `${day}${range}` return tzShort ? `${day}${range} (${tzShort})` : `${day}${range}`
} }

View File

@ -195,6 +195,7 @@ export type ComposeOpenPreset = Partial<
| "focusToOnMount" | "focusToOnMount"
| "focusBodyOnMount" | "focusBodyOnMount"
| "focusSubjectOnMount" | "focusSubjectOnMount"
| "maximized"
| "threading" | "threading"
| "showCc" | "showCc"
| "showBcc" | "showBcc"
@ -340,6 +341,7 @@ function mergeComposeFromPreset(
focusBodyOnMount: preset.focusBodyOnMount ?? base.focusBodyOnMount, focusBodyOnMount: preset.focusBodyOnMount ?? base.focusBodyOnMount,
focusSubjectOnMount: focusSubjectOnMount:
preset.focusSubjectOnMount ?? base.focusSubjectOnMount, preset.focusSubjectOnMount ?? base.focusSubjectOnMount,
maximized: preset.maximized ?? base.maximized,
threading: preset.threading !== undefined ? preset.threading : base.threading, threading: preset.threading !== undefined ? preset.threading : base.threading,
showCc: preset.showCc ?? base.showCc, showCc: preset.showCc ?? base.showCc,
showBcc: preset.showBcc ?? base.showBcc, showBcc: preset.showBcc ?? base.showBcc,

View File

@ -32,6 +32,7 @@ export interface Email {
id: string id: string
sender: string sender: string
senderEmail?: string senderEmail?: string
/** @deprecated Utiliser `getThreadMessageCount(email)` — ancien compteur participants. */
participantCount?: number participantCount?: number
subject: string subject: string
preview: string preview: string
@ -63,6 +64,13 @@ export interface Email {
snoozeWakeAt?: string snoozeWakeAt?: string
} }
/** Messages du fil : message principal + entrées `conversation`. */
export function getThreadMessageCount(
email: Pick<Email, "conversation">
): number {
return 1 + (email.conversation?.length ?? 0)
}
export const emails: Email[] = [ export const emails: Email[] = [
...demoCalendarInvitationEmails, ...demoCalendarInvitationEmails,
{ {

View File

@ -4,6 +4,8 @@ import type {
ComposeOpenPreset, ComposeOpenPreset,
ThreadComposeKind, ThreadComposeKind,
} from "@/lib/compose-context" } from "@/lib/compose-context"
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
import { readXsMatches } from "@/hooks/use-xs"
import { DEFAULT_IDENTITIES, SIGNATURES } from "@/lib/compose-context" import { DEFAULT_IDENTITIES, SIGNATURES } from "@/lib/compose-context"
import { formatMailDetailDate } from "@/lib/mail-date" import { formatMailDetailDate } from "@/lib/mail-date"
import { cleanSenderName } from "@/lib/sender-display" import { cleanSenderName } from "@/lib/sender-display"
@ -180,6 +182,24 @@ function forwardBodyHtml(email: Email): string {
return `<p></p>${header}${forwardConversationHtml(email)}` return `<p></p>${header}${forwardConversationHtml(email)}`
} }
/** Tablette / tactile : composition plein écran (dock maximisé) au lieu dinline sous le fil. */
export function withTouchFullscreenComposePreset(
preset: ComposeOpenPreset
): ComposeOpenPreset {
if (
typeof window === "undefined" ||
!readCoarsePointerMatches() ||
readXsMatches()
) {
return preset
}
return {
...preset,
placement: "dock",
maximized: true,
}
}
/** /**
* Preset pour une fenêtre de composition inline (réponse / transfert) rattachée au fil `email`. * Preset pour une fenêtre de composition inline (réponse / transfert) rattachée au fil `email`.
*/ */

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -1,6 +1,11 @@
/*
* Copyright (c) 2026 Eliott Guillaumin
* All rights reserved.
*/
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
allowedDevOrigins: ['192.168.0.20', '127.0.0.1', 'localhost', '100.120.4.66'],
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },

View File

@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --hostname 0.0.0.0",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",

File diff suppressed because one or more lines are too long