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:
parent
489c0d0c5c
commit
1fc4de1873
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,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">
|
<div className="hidden sm:block">
|
||||||
<Header
|
<Header
|
||||||
isXs={false}
|
isXs={false}
|
||||||
sidebarCollapsed={sidebarCollapsed}
|
sidebarCollapsed={sidebarCollapsed || touchNav}
|
||||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
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"
|
||||||
|
: touchNav || sidebarCollapsed
|
||||||
? "w-0 shrink-0 sm:w-[68px]"
|
? "w-0 shrink-0 sm:w-[68px]"
|
||||||
: "w-0 shrink-0 sm:w-60"
|
: "w-0 shrink-0 sm:w-60"
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
|
{!splitView ? (
|
||||||
<MobileBottomBar
|
<MobileBottomBar
|
||||||
sidebarOpen={!sidebarCollapsed}
|
sidebarOpen={!sidebarCollapsed}
|
||||||
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
|
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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
@ -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 */}
|
|
||||||
<div ref={threadComposeFooterRef} className="min-w-0 shrink-0">
|
|
||||||
{showReplyForwardBar ? (
|
{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
|
<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"
|
||||||
@ -1026,6 +1048,7 @@ export function EmailView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@ -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>
|
||||||
|
{!hideSearch ? (
|
||||||
<div className="hidden min-w-0 flex-1 max-w-3xl sm:flex">
|
<div className="hidden min-w-0 flex-1 max-w-3xl sm:flex">
|
||||||
<div className="relative flex w-full items-center">
|
<MailSearchBar />
|
||||||
<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>
|
|
||||||
</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 && (
|
||||||
|
|||||||
20
components/gmail/invitation-time-chip-text.tsx
Normal file
20
components/gmail/invitation-time-chip-text.tsx
Normal 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>
|
||||||
|
}
|
||||||
43
components/gmail/mail-search-bar.tsx
Normal file
43
components/gmail/mail-search-bar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
components/gmail/sidebar-nav-options-sheet.tsx
Normal file
161
components/gmail/sidebar-nav-options-sheet.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,12 +460,15 @@ function CategoryNavRow({
|
|||||||
|
|
||||||
if (isHiddenRow) {
|
if (isHiddenRow) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
{...dropHandlers}
|
{...dropHandlers}
|
||||||
|
{...touchRowProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 w-full min-w-0 shrink-0 items-center pl-6 pr-2 text-gray-500 transition-colors",
|
"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 ? "rounded-r-full" : "rounded-r-none",
|
||||||
isOver && "bg-yellow-100 text-gray-900"
|
isOver && "bg-yellow-100 text-gray-900",
|
||||||
|
touchRowClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@ -459,6 +498,7 @@ function CategoryNavRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{!touchNav && (
|
||||||
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@ -482,13 +522,33 @@ function CategoryNavRow({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{touchNav && (
|
||||||
|
<SidebarNavOptionsSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<SidebarNavSheetAction
|
||||||
|
onClick={() => {
|
||||||
|
onEnableNavLabel(item.id)
|
||||||
|
closeSheet()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Réactiver le libellé
|
||||||
|
</SidebarNavSheetAction>
|
||||||
|
</SidebarNavOptionsSheet>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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,11 +599,13 @@ 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}
|
||||||
>
|
>
|
||||||
|
{!touchNav && (
|
||||||
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@ -571,9 +634,28 @@ function CategoryNavRow({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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",
|
||||||
|
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 />
|
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
|
||||||
</Button>
|
</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(
|
||||||
|
"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"
|
"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}
|
||||||
|
|||||||
40
components/gmail/use-sidebar-touch-options.ts
Normal file
40
components/gmail/use-sidebar-touch-options.ts
Normal 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
52
hooks/use-long-press.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
47
hooks/use-mail-split-view.ts
Normal file
47
hooks/use-mail-split-view.ts
Normal 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
49
hooks/use-touch-nav.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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}`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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 d’inline 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
2
next-env.d.ts
vendored
@ -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.
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user