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-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 { Analytics } from '@vercel/analytics/next'
import './globals.css'
@ -12,14 +12,23 @@ export const metadata: Metadata = {
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({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" className="bg-white">
<body className="font-sans antialiased">
<html lang="en" className="h-dvh max-h-dvh overflow-hidden bg-white">
<body className="h-dvh max-h-dvh overflow-hidden font-sans antialiased touch-manipulation">
{children}
{process.env.NODE_ENV === 'production' && <Analytics />}
</body>

View File

@ -9,7 +9,9 @@ import {
useState,
type CSSProperties,
} 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 { Toaster } from "sonner"
import { useRouter, usePathname } from "next/navigation"
@ -46,12 +48,14 @@ function MailAppInner() {
const route = useMemo(() => parseMailSegments(segments), [segments])
const isXs = useIsXs()
const touchNav = useTouchNav()
const splitView = useMailSplitView()
const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit)
/** Start closed so narrow viewports match SSR/CSS before JS runs; desktop opens in layout. */
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
useLayoutEffect(() => {
if (!readXsMatches()) setSidebarCollapsed(false)
if (!readTouchNavMatches()) setSidebarCollapsed(false)
}, [])
useEffect(() => {
@ -89,7 +93,7 @@ function MailAppInner() {
page: 1,
mailId: null,
})
if (readXsMatches()) setSidebarCollapsed(true)
if (readTouchNavMatches()) setSidebarCollapsed(true)
},
[navigateRoute]
)
@ -106,26 +110,30 @@ 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">
{!splitView ? (
<div className="hidden sm:block">
<Header
isXs={false}
sidebarCollapsed={sidebarCollapsed}
sidebarCollapsed={sidebarCollapsed || touchNav}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
</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">
{!sidebarCollapsed && (
{!sidebarCollapsed && touchNav && (
<button
type="button"
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)}
/>
)}
<div
className={
sidebarCollapsed
touchNav && isXs
? "w-0 shrink-0"
: touchNav || sidebarCollapsed
? "w-0 shrink-0 sm:w-[68px]"
: "w-0 shrink-0 sm:w-60"
}
@ -135,6 +143,7 @@ function MailAppInner() {
onSelectFolder={handleSelectFolder}
collapsed={sidebarCollapsed}
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">
<Suspense>
@ -143,6 +152,8 @@ function MailAppInner() {
inboxTab={route.inboxTab}
listPage={route.page}
openMailId={route.mailId}
splitView={splitView}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
onMailRouteNavigate={navigateRoute}
onSelectFolder={handleSelectFolder}
onFolderUnreadCountsChange={setFolderUnreadCounts}
@ -151,10 +162,12 @@ function MailAppInner() {
</main>
<RightPanel />
</div>
{!splitView ? (
<MobileBottomBar
sidebarOpen={!sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
/>
) : null}
</div>
</SidebarNavProvider>
)
@ -165,13 +178,25 @@ export function MailAppShell({
}: {
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 (
<ComposeProvider>
<ScheduledMailProvider>
<EmailDragProvider>
<Suspense
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="min-h-0 flex-1 bg-app-canvas" />
</div>

View File

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

View File

@ -11,6 +11,7 @@ import {
Suspense,
} from "react"
import { useIsXs } from "@/hooks/use-xs"
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useEditor, EditorContent } from "@tiptap/react"
import { Editor, Node as TipTapNode, mergeAttributes, type Extensions } from "@tiptap/core"
@ -1697,7 +1698,9 @@ export function ComposeWindow({
: 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)]",
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]"
)
)}

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ import {
Reply,
ReplyAll,
Forward,
Smile,
MoreVertical,
Printer,
ExternalLink,
@ -68,9 +67,13 @@ import {
useComposeWindows,
DEFAULT_IDENTITIES,
type ThreadComposeKind,
type ComposeOpenPreset,
savedThreadDraftToComposePreset,
} 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 { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
import { ComposeWindow } from "@/components/gmail/compose-modal"
@ -110,6 +113,19 @@ const LABEL_DISPLAY_NAMES: Record<string, string> = {
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]"
/** 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 ── */
function SandboxedContent({
@ -437,7 +453,7 @@ function CollapsedMessage({
>
{senderInitial(name)}
</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">
<ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0">
<span className="truncate text-sm font-semibold text-[#202124]">{name}</span>
@ -513,7 +529,7 @@ function ExpandedMessage({
</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">
<ContactHoverCard
displayName={sender}
@ -698,6 +714,7 @@ function ExpandedMessage({
"px-4 pl-[68px]",
attachments.length > 0 ? "pb-0" : "pb-4"
)}
data-selectable-text
>
<SandboxedContent html={body} isSpam={isSpam} />
</div>
@ -806,13 +823,15 @@ export function EmailView({
const savedThreadDraft = savedThreadReplyDrafts[email.id]
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(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
threadComposeFooterRef.current?.scrollIntoView({
threadComposeAnchorRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
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(() => {
if (!savedThreadDraft || hasInlineForThread) return
openComposeWithInitial(savedThreadDraftToComposePreset(savedThreadDraft))
scrollThreadComposeIntoView()
openThreadCompose(savedThreadDraftToComposePreset(savedThreadDraft))
}, [
email.id,
savedThreadDraft,
hasInlineForThread,
openComposeWithInitial,
scrollThreadComposeIntoView,
openThreadCompose,
])
const startThreadCompose = useCallback(
(kind: ThreadComposeKind) => {
openComposeWithInitial(buildThreadComposePreset(email, kind))
scrollThreadComposeIntoView()
openThreadCompose(buildThreadComposePreset(email, kind))
},
[email, openComposeWithInitial, scrollThreadComposeIntoView]
[email, openThreadCompose]
)
const selfIdentity = DEFAULT_IDENTITIES[0]
const selfName = cleanSenderName(selfIdentity.name)
const showReplyForwardBar = !inlineCompose
const calendarInvitation = useMemo(
() => resolveParsedCalendarInvitation(email),
[email]
@ -853,7 +878,8 @@ export function EmailView({
return (
<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 */}
<div className="flex items-start gap-3 px-6 py-4">
<div className="min-w-0 flex-1">
@ -968,10 +994,13 @@ export function EmailView({
onPrintConversation={() => openConversationPrint(email)}
/>
{/* Réponse / transfert : flux normal, juste sous le dernier message */}
<div ref={threadComposeFooterRef} className="min-w-0 shrink-0">
{showReplyForwardBar ? (
<div className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px]">
<div
className={cn(
"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
type="button"
onClick={() => startThreadCompose("reply")}
@ -996,18 +1025,11 @@ export function EmailView({
<Forward className="h-[18px] w-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Transférer
</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>
) : null}
{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 h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
@ -1026,6 +1048,7 @@ export function EmailView({
</div>
</div>
) : null}
</div>
</div>
</TooltipProvider>

View File

@ -3,12 +3,12 @@
import { useState, useRef, useEffect } from "react"
import { Icon, addCollection } from "@iconify/react"
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"
addCollection(mdiIcons)
import { Input } from "@/components/ui/input"
import { UltiMailLogo } from "@/components/ultimail-logo"
import { MailSearchBar } from "@/components/gmail/mail-search-bar"
import { cn } from "@/lib/utils"
interface HeaderProps {
@ -16,6 +16,8 @@ interface HeaderProps {
/** Match `<main>` horizontal offset (same width as sidebar rail spacer). */
sidebarCollapsed: boolean
isXs?: boolean
/** Split pane shows search over the list column only. */
hideSearch?: boolean
}
const googleApps = [
@ -36,6 +38,7 @@ export function Header({
onToggleSidebar,
sidebarCollapsed,
isXs = false,
hideSearch = false,
}: HeaderProps) {
const [appsMenuOpen, setAppsMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
@ -82,21 +85,13 @@ export function Header({
>
<Search className="size-5 shrink-0 ml-0.5" />
</Button>
{!hideSearch ? (
<div className="hidden min-w-0 flex-1 max-w-3xl sm:flex">
<div className="relative flex w-full items-center">
<div className="absolute left-3 flex items-center text-gray-500">
<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>
<MailSearchBar />
</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">
{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,
} from "lucide-react"
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 {
useState,
useRef,
@ -71,6 +72,15 @@ import { Button } from "@/components/ui/button"
import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi"
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)
import {
@ -119,6 +129,8 @@ interface SidebarProps {
collapsed: boolean
/** Nombre de messages non lus par id de ligne (boîte, catégorie, dossier, libellé). */
folderUnreadCounts?: Record<string, number>
/** md+ split pane: mobile-style branding, no header compose. */
splitView?: boolean
}
const mainItems = [
@ -311,7 +323,7 @@ function SidebarNavDragHandle({
onDragEnd={onDragEnd}
onClick={(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 />
</span>
@ -326,6 +338,7 @@ function SidebarOverflowColumn({
isSelected,
hasUnread,
className,
showMenuButton = true,
children,
}: {
unread: number
@ -334,8 +347,26 @@ function SidebarOverflowColumn({
isSelected?: boolean
hasUnread?: boolean
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 menuHoverShow = `group-hover/${hoverGroup}:opacity-100 [&:has(button:focus-visible)]:opacity-100`
@ -376,6 +407,7 @@ function CategoryNavRow({
onSelectFolder,
onDisableNavLabel,
onEnableNavLabel,
touchNav,
variant = "listed",
}: {
item: CategoryNavSourceItem
@ -385,6 +417,7 @@ function CategoryNavRow({
onSelectFolder: (id: string) => void
onDisableNavLabel: (id: string) => void
onEnableNavLabel: (id: string) => void
touchNav: boolean
variant?: "listed" | "hidden"
}) {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
@ -393,6 +426,9 @@ function CategoryNavRow({
const isHiddenRow = variant === "hidden"
const showCategoryMenu = isSystemNavLabelId(item.id) && isExpanded
const hasUnread = unreadCount > 0
const touchMenuEnabled = touchNav && (isHiddenRow || showCategoryMenu)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchMenuEnabled)
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
@ -424,12 +460,15 @@ function CategoryNavRow({
if (isHiddenRow) {
return (
<>
<div
{...dropHandlers}
{...touchRowProps}
className={cn(
"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"
isOver && "bg-yellow-100 text-gray-900",
touchRowClassName
)}
>
<button
@ -459,6 +498,7 @@ function CategoryNavRow({
)}
</div>
</button>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
@ -482,13 +522,33 @@ function CategoryNavRow({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{touchNav && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
>
<SidebarNavSheetAction
onClick={() => {
onEnableNavLabel(item.id)
closeSheet()
}}
>
Réactiver le libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)}
</>
)
}
return (
<>
<div
{...dropHandlers}
{...touchRowProps}
className={cn(
"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),
@ -498,7 +558,8 @@ function CategoryNavRow({
? "bg-yellow-100 text-gray-900"
: hasUnread
? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100",
touchRowClassName
)}
>
<button
@ -538,11 +599,13 @@ function CategoryNavRow({
{showCategoryMenu && (
<SidebarOverflowColumn
unread={unreadCount}
menuOpen={menuOpen}
menuOpen={menuOpen || sheetOpen}
hoverGroup="catnav"
isSelected={isSelected}
hasUnread={hasUnread}
showMenuButton={!touchNav}
>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
@ -571,9 +634,28 @@ function CategoryNavRow({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarOverflowColumn>
)}
</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,
collapsed,
folderUnreadCounts = {},
splitView = false,
}: SidebarProps) {
const { openCompose } = useComposeActions()
const [hoverExpanded, setHoverExpanded] = useState(false)
@ -589,8 +672,11 @@ export function Sidebar({
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(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 {
folderTree,
@ -806,7 +892,7 @@ export function Sidebar({
}, [selectedFolder])
const handleMouseEnter = () => {
if (readXsMatches()) return
if (readTouchNavMatches()) return
if (collapsed) {
hoverTimeoutRef.current = setTimeout(() => {
setHoverExpanded(true)
@ -819,10 +905,14 @@ export function Sidebar({
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
if (readXsMatches()) return
if (readTouchNavMatches()) return
setHoverExpanded(false)
}
useEffect(() => {
if (touchNav) setHoverExpanded(false)
}, [touchNav, collapsed])
useEffect(() => {
return () => {
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). */
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). */
const NavItem = ({
item,
@ -923,6 +1017,8 @@ export function Sidebar({
const [subfolderName, setSubfolderName] = useState("")
const folderRenameInputRef = useRef<HTMLInputElement>(null)
const subfolderNameInputRef = useRef<HTMLInputElement>(null)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchNav && isExpanded)
useEffect(() => {
setRenameDraft(node.label)
@ -936,7 +1032,7 @@ export function Sidebar({
}
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen)
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
const prefs = getNavItemPrefs(node.id)
const moveTargets = useMemo(
@ -998,14 +1094,15 @@ export function Sidebar({
}
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",
isStickyBranch && "sticky border-b border-gray-200/70",
isStickyBranch && !isSelected && !rowHoverHeld && "bg-app-canvas",
isSelected && "bg-[#d3e3fd] font-medium text-gray-900",
!isSelected && hasUnread && "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 = {
paddingLeft: 24 + depth * 16,
@ -1015,12 +1112,14 @@ export function Sidebar({
const overflowMenu = (
<SidebarOverflowColumn
unread={unread}
menuOpen={menuOpen}
menuOpen={menuOpen || sheetOpen}
hoverGroup="folderrow"
isSelected={isSelected}
hasUnread={hasUnread}
className={cn(!isExpanded && "hidden", "mr-[-11px]")}
showMenuButton={!touchNav}
>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
@ -1116,9 +1215,115 @@ export function Sidebar({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</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 active = navDragRef.current
if (active?.kind === "folder" && active.id !== node.id) {
@ -1175,12 +1380,10 @@ export function Sidebar({
beginNavDrag(payload, rowEl)
}
return (
<>
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>
const folderRowEl = (
<div
data-nav-row
{...touchRowProps}
onDragEnter={onFolderRowDragEnter}
onDragOver={onFolderRowDragOver}
onDragLeave={onFolderRowDragLeave}
@ -1263,7 +1466,15 @@ export function Sidebar({
</div>
{overflowMenu}
</div>
</ContextMenuTrigger>
)
return (
<>
{touchNav ? (
folderRowEl
) : (
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>{folderRowEl}</ContextMenuTrigger>
<ContextMenuContent className={folderMenuSurface}>
{colorSub("context")}
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
@ -1341,6 +1552,8 @@ export function Sidebar({
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
{folderOptionsSheet}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent
@ -1574,6 +1787,8 @@ export function Sidebar({
const labelRenameInputRef = useRef<HTMLInputElement>(null)
const sublabelNameInputRef = useRef<HTMLInputElement>(null)
const canDragLabel = labelRowExpanded && !isSystemNavLabelId(item.id)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchNav && labelRowExpanded)
useEffect(() => {
setRenameDraft(item.label)
@ -1587,7 +1802,7 @@ export function Sidebar({
}
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen)
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
const prefs = getNavItemPrefs(item.id)
const labelDotClass = item.color ?? "bg-gray-400"
@ -1643,7 +1858,7 @@ export function Sidebar({
}
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),
isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium"
@ -1653,7 +1868,8 @@ export function Sidebar({
? "bg-gray-100 text-gray-900"
: hasUnread
? "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) => {
@ -1717,12 +1933,14 @@ export function Sidebar({
const overflowMenu = labelRowExpanded ? (
<SidebarOverflowColumn
unread={unreadCount}
menuOpen={menuOpen}
menuOpen={menuOpen || sheetOpen}
hoverGroup="labelrow"
isSelected={isSelected}
hasUnread={hasUnread}
className="mr-[-7px]"
showMenuButton={!touchNav}
>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
@ -1809,15 +2027,110 @@ export function Sidebar({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarOverflowColumn>
) : null
return (
<>
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>
const labelOptionsSheet = touchNav && labelRowExpanded && (
<SidebarNavOptionsSheet
open={sheetOpen}
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
data-nav-row
{...touchRowProps}
onDragEnter={onLabelRowDragEnter}
onDragOver={onLabelRowDragOver}
onDragLeave={onLabelRowDragLeave}
@ -1860,7 +2173,15 @@ export function Sidebar({
</div>
{overflowMenu}
</div>
</ContextMenuTrigger>
)
return (
<>
{touchNav ? (
labelRowEl
) : (
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>{labelRowEl}</ContextMenuTrigger>
<ContextMenuContent className={labelMenuSurface}>
{colorSub("context")}
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
@ -1929,6 +2250,8 @@ export function Sidebar({
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
{labelOptionsSheet}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent
@ -2029,27 +2352,59 @@ export function Sidebar({
return (
<aside
ref={sidebarRef}
data-sidebar
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
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]",
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",
collapsed && "max-sm:-translate-x-full max-sm:pointer-events-none"
!touchNav && hoverExpanded && "shadow-xl border-r border-gray-200",
isOverlayOpen && "z-50 shadow-xl border-r border-gray-200",
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">
<UltiMailLogo className="min-h-8" />
<Button variant="ghost" size="icon" className="size-9 shrink-0 text-gray-600" aria-label="Réglages">
<div
className={cn(
"flex shrink-0 items-center bg-app-canvas",
splitView
? 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
className={cn(
"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
@ -2097,6 +2452,7 @@ export function Sidebar({
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/>
@ -2151,6 +2507,7 @@ export function Sidebar({
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/>
@ -2185,6 +2542,7 @@ export function Sidebar({
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
variant="hidden"
@ -2198,7 +2556,7 @@ export function Sidebar({
{/* Dossiers (hiérarchie : chevron = replier / déplier uniquement) */}
<div className="mt-3 pt-1">
<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}
>
<Folder className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
@ -2234,7 +2592,7 @@ export function Sidebar({
{/* Labels */}
<div className="mt-3 pt-1">
<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}
>
<Tag className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
@ -2270,10 +2628,12 @@ export function Sidebar({
</div>
{/* Sortbot */}
<div className={cn(
"z-30 mt-auto bg-app-canvas pt-2",
<div
className={cn(
"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
type="button"
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 {
const locale = fr
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 })
}
const tzShort =
new Intl.DateTimeFormat(undefined, { timeZone: tz, timeZoneName: "short" })
.formatToParts(start)
.find((p) => p.type === "timeZoneName")?.value ?? ""
const tzShort = formatStableUtcOffsetLabel(start, tz)
const range = `${startLabel} ${endLabel}`
return tzShort ? `${day}${range} (${tzShort})` : `${day}${range}`
}

View File

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

View File

@ -32,6 +32,7 @@ export interface Email {
id: string
sender: string
senderEmail?: string
/** @deprecated Utiliser `getThreadMessageCount(email)` — ancien compteur participants. */
participantCount?: number
subject: string
preview: string
@ -63,6 +64,13 @@ export interface Email {
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[] = [
...demoCalendarInvitationEmails,
{

View File

@ -4,6 +4,8 @@ import type {
ComposeOpenPreset,
ThreadComposeKind,
} 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 { formatMailDetailDate } from "@/lib/mail-date"
import { cleanSenderName } from "@/lib/sender-display"
@ -180,6 +182,24 @@ function forwardBodyHtml(email: Email): string {
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`.
*/

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <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
// 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} */
const nextConfig = {
output: 'standalone',
allowedDevOrigins: ['192.168.0.20', '127.0.0.1', 'localhost', '100.120.4.66'],
typescript: {
ignoreBuildErrors: true,
},

View File

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

File diff suppressed because one or more lines are too long