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-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 { Analytics } from '@vercel/analytics/next'
|
||||
import './globals.css'
|
||||
@ -12,14 +12,23 @@ export const metadata: Metadata = {
|
||||
generator: 'v0.app',
|
||||
}
|
||||
|
||||
/** Fit visible viewport on tablet/mobile; disable pinch/double-tap zoom on the shell. */
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: 'cover',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="bg-white">
|
||||
<body className="font-sans antialiased">
|
||||
<html lang="en" className="h-dvh max-h-dvh overflow-hidden bg-white">
|
||||
<body className="h-dvh max-h-dvh overflow-hidden font-sans antialiased touch-manipulation">
|
||||
{children}
|
||||
{process.env.NODE_ENV === 'production' && <Analytics />}
|
||||
</body>
|
||||
|
||||
@ -9,7 +9,9 @@ import {
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react"
|
||||
import { readXsMatches, useIsXs } from "@/hooks/use-xs"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
|
||||
import { useMailSplitView } from "@/hooks/use-mail-split-view"
|
||||
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
|
||||
import { Toaster } from "sonner"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
@ -46,12 +48,14 @@ function MailAppInner() {
|
||||
const route = useMemo(() => parseMailSegments(segments), [segments])
|
||||
|
||||
const isXs = useIsXs()
|
||||
const touchNav = useTouchNav()
|
||||
const splitView = useMailSplitView()
|
||||
const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit)
|
||||
/** Start closed so narrow viewports match SSR/CSS before JS runs; desktop opens in layout. */
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!readXsMatches()) setSidebarCollapsed(false)
|
||||
if (!readTouchNavMatches()) setSidebarCollapsed(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@ -89,7 +93,7 @@ function MailAppInner() {
|
||||
page: 1,
|
||||
mailId: null,
|
||||
})
|
||||
if (readXsMatches()) setSidebarCollapsed(true)
|
||||
if (readTouchNavMatches()) setSidebarCollapsed(true)
|
||||
},
|
||||
[navigateRoute]
|
||||
)
|
||||
@ -106,28 +110,32 @@ function MailAppInner() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex h-screen flex-col bg-app-canvas">
|
||||
<div className="hidden sm:block">
|
||||
<Header
|
||||
isXs={false}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
|
||||
{!splitView ? (
|
||||
<div className="hidden sm:block">
|
||||
<Header
|
||||
isXs={false}
|
||||
sidebarCollapsed={sidebarCollapsed || touchNav}
|
||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative flex min-h-0 flex-1 gap-0 overflow-hidden bg-app-canvas pl-0 pr-0 pb-1 pt-1 sm:gap-1 sm:pl-1">
|
||||
{!sidebarCollapsed && (
|
||||
{!sidebarCollapsed && touchNav && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fermer le menu"
|
||||
className="absolute inset-0 z-30 bg-black/20 sm:hidden"
|
||||
className="absolute inset-0 z-30 bg-black/20"
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
sidebarCollapsed
|
||||
? "w-0 shrink-0 sm:w-[68px]"
|
||||
: "w-0 shrink-0 sm:w-60"
|
||||
touchNav && isXs
|
||||
? "w-0 shrink-0"
|
||||
: touchNav || sidebarCollapsed
|
||||
? "w-0 shrink-0 sm:w-[68px]"
|
||||
: "w-0 shrink-0 sm:w-60"
|
||||
}
|
||||
/>
|
||||
<Sidebar
|
||||
@ -135,6 +143,7 @@ function MailAppInner() {
|
||||
onSelectFolder={handleSelectFolder}
|
||||
collapsed={sidebarCollapsed}
|
||||
folderUnreadCounts={folderUnreadCounts}
|
||||
splitView={splitView}
|
||||
/>
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-none bg-white shadow-sm sm:rounded-2xl">
|
||||
<Suspense>
|
||||
@ -143,6 +152,8 @@ function MailAppInner() {
|
||||
inboxTab={route.inboxTab}
|
||||
listPage={route.page}
|
||||
openMailId={route.mailId}
|
||||
splitView={splitView}
|
||||
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
|
||||
onMailRouteNavigate={navigateRoute}
|
||||
onSelectFolder={handleSelectFolder}
|
||||
onFolderUnreadCountsChange={setFolderUnreadCounts}
|
||||
@ -151,10 +162,12 @@ function MailAppInner() {
|
||||
</main>
|
||||
<RightPanel />
|
||||
</div>
|
||||
<MobileBottomBar
|
||||
sidebarOpen={!sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
|
||||
/>
|
||||
{!splitView ? (
|
||||
<MobileBottomBar
|
||||
sidebarOpen={!sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SidebarNavProvider>
|
||||
)
|
||||
@ -165,13 +178,25 @@ export function MailAppShell({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const blockPinch = (event: Event) => event.preventDefault()
|
||||
document.addEventListener("gesturestart", blockPinch, { passive: false })
|
||||
document.addEventListener("gesturechange", blockPinch, { passive: false })
|
||||
document.addEventListener("gestureend", blockPinch, { passive: false })
|
||||
return () => {
|
||||
document.removeEventListener("gesturestart", blockPinch)
|
||||
document.removeEventListener("gesturechange", blockPinch)
|
||||
document.removeEventListener("gestureend", blockPinch)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ComposeProvider>
|
||||
<ScheduledMailProvider>
|
||||
<EmailDragProvider>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-screen flex-col bg-app-canvas">
|
||||
<div className="flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
|
||||
<div className="hidden h-14 shrink-0 border-b border-gray-200 bg-white sm:block" />
|
||||
<div className="min-h-0 flex-1 bg-app-canvas" />
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { InvitationTimeChipText } from "@/components/gmail/invitation-time-chip-text"
|
||||
import { Icon } from "@iconify/react"
|
||||
import { ThumbsDown, ThumbsUp, Users, MoreVertical } from "lucide-react"
|
||||
import {
|
||||
VIDEO_CONFERENCE_LOGOS,
|
||||
formatInvitationAttendeeLine,
|
||||
formatInvitationTimeChip,
|
||||
type ParsedCalendarInvitation,
|
||||
} from "@/lib/calendar-invitation"
|
||||
import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
|
||||
@ -51,11 +51,6 @@ export function CalendarInvitationPreview({
|
||||
}) {
|
||||
ensureVcLogosCollection()
|
||||
|
||||
const timeChip = useMemo(
|
||||
() => formatInvitationTimeChip(invitation.start, invitation.end),
|
||||
[invitation.start, invitation.end]
|
||||
)
|
||||
|
||||
const { organizerLine, othersLine } = useMemo(
|
||||
() => attendeeDisplayList(invitation),
|
||||
[invitation]
|
||||
@ -76,7 +71,10 @@ export function CalendarInvitationPreview({
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-[#5f6368]">
|
||||
<Icon icon={confIcon} className="size-5 shrink-0" aria-hidden />
|
||||
<span>{timeChip}</span>
|
||||
<InvitationTimeChipText
|
||||
start={invitation.start}
|
||||
end={invitation.end}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-normal leading-snug text-[#202124]">
|
||||
{invitation.summary}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
Suspense,
|
||||
} from "react"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||
import { useEditor, EditorContent } from "@tiptap/react"
|
||||
import { Editor, Node as TipTapNode, mergeAttributes, type Extensions } from "@tiptap/core"
|
||||
@ -1697,7 +1698,9 @@ export function ComposeWindow({
|
||||
: cn(
|
||||
"rounded-t-lg shadow-[0_-2px_8px_rgba(0,0,0,0.08),_-4px_0_12px_rgba(0,0,0,0.12),_4px_0_12px_rgba(0,0,0,0.12)]",
|
||||
compose.maximized
|
||||
? "fixed inset-12 z-60 rounded-lg"
|
||||
? readCoarsePointerMatches()
|
||||
? "fixed inset-0 z-60 rounded-none"
|
||||
: "fixed inset-12 z-60 rounded-lg"
|
||||
: "h-[480px] w-[500px]"
|
||||
)
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,6 @@ import {
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Forward,
|
||||
Smile,
|
||||
MoreVertical,
|
||||
Printer,
|
||||
ExternalLink,
|
||||
@ -68,9 +67,13 @@ import {
|
||||
useComposeWindows,
|
||||
DEFAULT_IDENTITIES,
|
||||
type ThreadComposeKind,
|
||||
type ComposeOpenPreset,
|
||||
savedThreadDraftToComposePreset,
|
||||
} from "@/lib/compose-context"
|
||||
import { buildThreadComposePreset } from "@/lib/thread-compose-preset"
|
||||
import {
|
||||
buildThreadComposePreset,
|
||||
withTouchFullscreenComposePreset,
|
||||
} from "@/lib/thread-compose-preset"
|
||||
import { openConversationPrint } from "@/lib/print-conversation"
|
||||
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
|
||||
import { ComposeWindow } from "@/components/gmail/compose-modal"
|
||||
@ -110,6 +113,19 @@ const LABEL_DISPLAY_NAMES: Record<string, string> = {
|
||||
const MESSAGE_MORE_MENU_CLASS =
|
||||
"min-w-[280px] rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg [&_[data-slot=dropdown-menu-item]]:gap-3 [&_[data-slot=dropdown-menu-item]]:rounded-none [&_[data-slot=dropdown-menu-item]]:px-3 [&_[data-slot=dropdown-menu-item]]:py-2 [&_[data-slot=dropdown-menu-item]]:text-sm [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1 [&_[data-slot=dropdown-menu-separator]]:bg-[#eceff1]"
|
||||
|
||||
/** Scroll zone du corps du message (preview remplit le panneau parent). */
|
||||
const EMAIL_PREVIEW_SCROLL_CLASS =
|
||||
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain outline-none " +
|
||||
"[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " +
|
||||
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white " +
|
||||
"[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white [&::-webkit-scrollbar-track]:shadow-none " +
|
||||
"[&::-webkit-scrollbar-thumb]:rounded-none [&::-webkit-scrollbar-thumb]:border-0 [&::-webkit-scrollbar-thumb]:shadow-none " +
|
||||
"[&::-webkit-scrollbar-thumb]:bg-[#9aa0a6] hover:[&::-webkit-scrollbar-thumb]:bg-[#5f6368] " +
|
||||
"[&::-webkit-scrollbar-corner]:border-0 [&::-webkit-scrollbar-corner]:bg-white"
|
||||
|
||||
const REPLY_BAR_SURFACE_CLASS =
|
||||
"bg-[linear-gradient(to_bottom,rgba(255,255,255,0)_0%,#ffffff_0.75rem,#ffffff_100%)] pt-3"
|
||||
|
||||
/* ── Sandboxed iframe for HTML body ── */
|
||||
|
||||
function SandboxedContent({
|
||||
@ -437,7 +453,7 @@ function CollapsedMessage({
|
||||
>
|
||||
{senderInitial(name)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1">
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0">
|
||||
<span className="truncate text-sm font-semibold text-[#202124]">{name}</span>
|
||||
@ -513,7 +529,7 @@ function ExpandedMessage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1">
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
|
||||
<div className="min-w-0 truncate text-sm leading-snug">
|
||||
<ContactHoverCard
|
||||
displayName={sender}
|
||||
@ -698,6 +714,7 @@ function ExpandedMessage({
|
||||
"px-4 pl-[68px]",
|
||||
attachments.length > 0 ? "pb-0" : "pb-4"
|
||||
)}
|
||||
data-selectable-text
|
||||
>
|
||||
<SandboxedContent html={body} isSpam={isSpam} />
|
||||
</div>
|
||||
@ -806,13 +823,15 @@ export function EmailView({
|
||||
|
||||
const savedThreadDraft = savedThreadReplyDrafts[email.id]
|
||||
const hasInlineForThread = Boolean(inlineCompose)
|
||||
const showReplyForwardBar = !inlineCompose
|
||||
|
||||
const threadComposeFooterRef = useRef<HTMLDivElement>(null)
|
||||
const previewScrollRef = useRef<HTMLDivElement>(null)
|
||||
const threadComposeAnchorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scrollThreadComposeIntoView = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
threadComposeFooterRef.current?.scrollIntoView({
|
||||
threadComposeAnchorRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
inline: "nearest",
|
||||
@ -821,31 +840,37 @@ export function EmailView({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const openThreadCompose = useCallback(
|
||||
(preset: ComposeOpenPreset) => {
|
||||
const resolved = withTouchFullscreenComposePreset(preset)
|
||||
openComposeWithInitial(resolved)
|
||||
if (resolved.placement === "inline") {
|
||||
scrollThreadComposeIntoView()
|
||||
}
|
||||
},
|
||||
[openComposeWithInitial, scrollThreadComposeIntoView]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!savedThreadDraft || hasInlineForThread) return
|
||||
openComposeWithInitial(savedThreadDraftToComposePreset(savedThreadDraft))
|
||||
scrollThreadComposeIntoView()
|
||||
openThreadCompose(savedThreadDraftToComposePreset(savedThreadDraft))
|
||||
}, [
|
||||
email.id,
|
||||
savedThreadDraft,
|
||||
hasInlineForThread,
|
||||
openComposeWithInitial,
|
||||
scrollThreadComposeIntoView,
|
||||
openThreadCompose,
|
||||
])
|
||||
|
||||
const startThreadCompose = useCallback(
|
||||
(kind: ThreadComposeKind) => {
|
||||
openComposeWithInitial(buildThreadComposePreset(email, kind))
|
||||
scrollThreadComposeIntoView()
|
||||
openThreadCompose(buildThreadComposePreset(email, kind))
|
||||
},
|
||||
[email, openComposeWithInitial, scrollThreadComposeIntoView]
|
||||
[email, openThreadCompose]
|
||||
)
|
||||
|
||||
const selfIdentity = DEFAULT_IDENTITIES[0]
|
||||
const selfName = cleanSenderName(selfIdentity.name)
|
||||
|
||||
const showReplyForwardBar = !inlineCompose
|
||||
|
||||
const calendarInvitation = useMemo(
|
||||
() => resolveParsedCalendarInvitation(email),
|
||||
[email]
|
||||
@ -853,7 +878,8 @@ export function EmailView({
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div ref={previewScrollRef} className={EMAIL_PREVIEW_SCROLL_CLASS}>
|
||||
{/* Subject header */}
|
||||
<div className="flex items-start gap-3 px-6 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
@ -968,10 +994,13 @@ export function EmailView({
|
||||
onPrintConversation={() => openConversationPrint(email)}
|
||||
/>
|
||||
|
||||
{/* Réponse / transfert : flux normal, juste sous le dernier message */}
|
||||
<div ref={threadComposeFooterRef} className="min-w-0 shrink-0">
|
||||
{showReplyForwardBar ? (
|
||||
<div className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px]">
|
||||
{showReplyForwardBar ? (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky bottom-0 z-10 mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px]",
|
||||
REPLY_BAR_SURFACE_CLASS
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startThreadCompose("reply")}
|
||||
@ -996,18 +1025,11 @@ export function EmailView({
|
||||
<Forward className="h-[18px] w-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
|
||||
Transférer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-[#dadce0] bg-white text-[#5f6368] shadow-sm transition-shadow hover:bg-[#f6f9fe] hover:shadow-md"
|
||||
aria-label="Réaction"
|
||||
>
|
||||
<Smile className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
{inlineCompose ? (
|
||||
<div className="px-4 pb-6 pt-2">
|
||||
{inlineCompose ? (
|
||||
<div ref={threadComposeAnchorRef} className="mt-6 px-4 pb-6 pl-[68px]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
|
||||
@ -1025,7 +1047,8 @@ export function EmailView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Icon, addCollection } from "@iconify/react"
|
||||
import { icons as mdiIcons } from "@iconify-json/mdi"
|
||||
import { Menu, Search, SlidersHorizontal, Pencil } from "lucide-react"
|
||||
import { Menu, Search, Pencil } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
addCollection(mdiIcons)
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { UltiMailLogo } from "@/components/ultimail-logo"
|
||||
import { MailSearchBar } from "@/components/gmail/mail-search-bar"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface HeaderProps {
|
||||
@ -16,6 +16,8 @@ interface HeaderProps {
|
||||
/** Match `<main>` horizontal offset (same width as sidebar rail spacer). */
|
||||
sidebarCollapsed: boolean
|
||||
isXs?: boolean
|
||||
/** Split pane shows search over the list column only. */
|
||||
hideSearch?: boolean
|
||||
}
|
||||
|
||||
const googleApps = [
|
||||
@ -36,6 +38,7 @@ export function Header({
|
||||
onToggleSidebar,
|
||||
sidebarCollapsed,
|
||||
isXs = false,
|
||||
hideSearch = false,
|
||||
}: HeaderProps) {
|
||||
const [appsMenuOpen, setAppsMenuOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
@ -82,21 +85,13 @@ export function Header({
|
||||
>
|
||||
<Search className="size-5 shrink-0 ml-0.5" />
|
||||
</Button>
|
||||
<div className="hidden min-w-0 flex-1 max-w-3xl sm:flex">
|
||||
<div className="relative flex w-full items-center">
|
||||
<div className="absolute left-3 flex items-center text-gray-500">
|
||||
<Search className="size-5 shrink-0 ml-0.5" />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Rechercher dans les messages"
|
||||
className="h-12 w-full rounded-full border-0 bg-[#eaf1fb] pl-12 pr-12 text-sm focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:bg-white"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="absolute right-2 text-gray-600">
|
||||
<SlidersHorizontal className="h-5 w-5" />
|
||||
</Button>
|
||||
{!hideSearch ? (
|
||||
<div className="hidden min-w-0 flex-1 max-w-3xl sm:flex">
|
||||
<MailSearchBar />
|
||||
</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">
|
||||
{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,
|
||||
} from "lucide-react"
|
||||
import { cn, formatCount } from "@/lib/utils"
|
||||
import { readXsMatches } from "@/hooks/use-xs"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
@ -71,6 +72,15 @@ import { Button } from "@/components/ui/button"
|
||||
import { Icon, addCollection } from "@iconify/react"
|
||||
import { icons as mdiIcons } from "@iconify-json/mdi"
|
||||
import { UltiMailLogo } from "@/components/ultimail-logo"
|
||||
import {
|
||||
SidebarNavOptionsSheet,
|
||||
SidebarNavSheetAction,
|
||||
SidebarNavSheetCheckOption,
|
||||
SidebarNavSheetColorPicker,
|
||||
SidebarNavSheetDivider,
|
||||
SidebarNavSheetSectionLabel,
|
||||
} from "@/components/gmail/sidebar-nav-options-sheet"
|
||||
import { useSidebarTouchOptionsMenu } from "@/components/gmail/use-sidebar-touch-options"
|
||||
|
||||
addCollection(mdiIcons)
|
||||
import {
|
||||
@ -119,6 +129,8 @@ interface SidebarProps {
|
||||
collapsed: boolean
|
||||
/** Nombre de messages non lus par id de ligne (boîte, catégorie, dossier, libellé). */
|
||||
folderUnreadCounts?: Record<string, number>
|
||||
/** md+ split pane: mobile-style branding, no header compose. */
|
||||
splitView?: boolean
|
||||
}
|
||||
|
||||
const mainItems = [
|
||||
@ -311,7 +323,7 @@ function SidebarNavDragHandle({
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex h-8 w-4 shrink-0 cursor-grab items-center justify-center text-gray-400 opacity-50 transition-opacity hover:opacity-100 active:cursor-grabbing group-hover/folderrow:opacity-100 group-hover/labelrow:opacity-100"
|
||||
className="pointer-events-none absolute left-0 top-1/2 z-10 flex h-8 w-4 -translate-y-1/2 cursor-grab items-center justify-center text-gray-400 opacity-0 transition-opacity hover:opacity-100 active:cursor-grabbing group-hover/folderrow:pointer-events-auto group-hover/folderrow:opacity-100 group-hover/labelrow:pointer-events-auto group-hover/labelrow:opacity-100"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" aria-hidden />
|
||||
</span>
|
||||
@ -326,6 +338,7 @@ function SidebarOverflowColumn({
|
||||
isSelected,
|
||||
hasUnread,
|
||||
className,
|
||||
showMenuButton = true,
|
||||
children,
|
||||
}: {
|
||||
unread: number
|
||||
@ -334,8 +347,26 @@ function SidebarOverflowColumn({
|
||||
isSelected?: boolean
|
||||
hasUnread?: boolean
|
||||
className?: string
|
||||
children: ReactNode
|
||||
showMenuButton?: boolean
|
||||
children?: ReactNode
|
||||
}) {
|
||||
if (!showMenuButton) {
|
||||
if (unread <= 0) return null
|
||||
return (
|
||||
<div className={cn("relative h-8 w-8 shrink-0", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-full items-center justify-center text-xs tabular-nums leading-none",
|
||||
isSelected && "font-medium",
|
||||
hasUnread && !isSelected && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{formatCount(unread)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const countHoverHide = `group-hover/${hoverGroup}:opacity-0`
|
||||
const menuHoverShow = `group-hover/${hoverGroup}:opacity-100 [&:has(button:focus-visible)]:opacity-100`
|
||||
|
||||
@ -376,6 +407,7 @@ function CategoryNavRow({
|
||||
onSelectFolder,
|
||||
onDisableNavLabel,
|
||||
onEnableNavLabel,
|
||||
touchNav,
|
||||
variant = "listed",
|
||||
}: {
|
||||
item: CategoryNavSourceItem
|
||||
@ -385,6 +417,7 @@ function CategoryNavRow({
|
||||
onSelectFolder: (id: string) => void
|
||||
onDisableNavLabel: (id: string) => void
|
||||
onEnableNavLabel: (id: string) => void
|
||||
touchNav: boolean
|
||||
variant?: "listed" | "hidden"
|
||||
}) {
|
||||
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
|
||||
@ -393,6 +426,9 @@ function CategoryNavRow({
|
||||
const isHiddenRow = variant === "hidden"
|
||||
const showCategoryMenu = isSystemNavLabelId(item.id) && isExpanded
|
||||
const hasUnread = unreadCount > 0
|
||||
const touchMenuEnabled = touchNav && (isHiddenRow || showCategoryMenu)
|
||||
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
|
||||
useSidebarTouchOptionsMenu(touchMenuEnabled)
|
||||
|
||||
const handleMenuOpenChange = (open: boolean) => {
|
||||
setMenuOpen(open)
|
||||
@ -424,71 +460,95 @@ function CategoryNavRow({
|
||||
|
||||
if (isHiddenRow) {
|
||||
return (
|
||||
<div
|
||||
{...dropHandlers}
|
||||
className={cn(
|
||||
"flex h-8 w-full min-w-0 shrink-0 items-center pl-6 pr-2 text-gray-500 transition-colors",
|
||||
isOver ? "rounded-r-full" : "rounded-r-none",
|
||||
isOver && "bg-yellow-100 text-gray-900"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectFolder(item.id)}
|
||||
className="flex h-8 min-w-0 flex-1 items-center gap-4 rounded-r-none py-0 pr-1 text-left outline-none hover:rounded-r-full hover:bg-gray-50"
|
||||
<>
|
||||
<div
|
||||
{...dropHandlers}
|
||||
{...touchRowProps}
|
||||
className={cn(
|
||||
"flex h-8 w-full min-w-0 shrink-0 items-center pl-6 pr-2 text-gray-500 transition-colors",
|
||||
isOver ? "rounded-r-full" : "rounded-r-none",
|
||||
isOver && "bg-yellow-100 text-gray-900",
|
||||
touchRowClassName
|
||||
)}
|
||||
>
|
||||
{rowIcon}
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-4">
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm leading-5",
|
||||
hasUnread && "font-semibold text-gray-900"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectFolder(item.id)}
|
||||
className="flex h-8 min-w-0 flex-1 items-center gap-4 rounded-r-none py-0 pr-1 text-left outline-none hover:rounded-r-full hover:bg-gray-50"
|
||||
>
|
||||
{rowIcon}
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-4">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs tabular-nums leading-none text-gray-700",
|
||||
hasUnread && "font-semibold"
|
||||
"min-w-0 flex-1 truncate text-sm leading-5",
|
||||
hasUnread && "font-semibold text-gray-900"
|
||||
)}
|
||||
>
|
||||
{formatCount(unreadCount)}
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={menuTriggerRef}
|
||||
type="button"
|
||||
className={sidebarOverflowMenuButtonClass}
|
||||
aria-label={`Options pour ${item.label}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs tabular-nums leading-none text-gray-700",
|
||||
hasUnread && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{formatCount(unreadCount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{!touchNav && (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={menuTriggerRef}
|
||||
type="button"
|
||||
className={sidebarOverflowMenuButtonClass}
|
||||
aria-label={`Options pour ${item.label}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onEnableNavLabel(item.id)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
Réactiver le libellé
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{touchNav && (
|
||||
<SidebarNavOptionsSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title={item.label}
|
||||
>
|
||||
<SidebarNavSheetAction
|
||||
onClick={() => {
|
||||
onEnableNavLabel(item.id)
|
||||
setMenuOpen(false)
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Réactiver le libellé
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</SidebarNavSheetAction>
|
||||
</SidebarNavOptionsSheet>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
{...dropHandlers}
|
||||
{...touchRowProps}
|
||||
className={cn(
|
||||
"group/catnav flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center pl-6 pr-2 transition-colors",
|
||||
navRowRoundedWhenActive(isSelected || isOver),
|
||||
@ -498,7 +558,8 @@ function CategoryNavRow({
|
||||
? "bg-yellow-100 text-gray-900"
|
||||
: hasUnread
|
||||
? "text-gray-900 hover:bg-gray-100"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
touchRowClassName
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@ -538,42 +599,63 @@ function CategoryNavRow({
|
||||
{showCategoryMenu && (
|
||||
<SidebarOverflowColumn
|
||||
unread={unreadCount}
|
||||
menuOpen={menuOpen}
|
||||
menuOpen={menuOpen || sheetOpen}
|
||||
hoverGroup="catnav"
|
||||
isSelected={isSelected}
|
||||
hasUnread={hasUnread}
|
||||
showMenuButton={!touchNav}
|
||||
>
|
||||
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={menuTriggerRef}
|
||||
type="button"
|
||||
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
|
||||
aria-label={`Options pour ${item.label}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem disabled className="text-gray-400">
|
||||
Afficher
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onDisableNavLabel(item.id)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
Désactiver le libellé
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!touchNav && (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={menuTriggerRef}
|
||||
type="button"
|
||||
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
|
||||
aria-label={`Options pour ${item.label}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem disabled className="text-gray-400">
|
||||
Afficher
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onDisableNavLabel(item.id)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
Désactiver le libellé
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarOverflowColumn>
|
||||
)}
|
||||
</div>
|
||||
{touchNav && showCategoryMenu && (
|
||||
<SidebarNavOptionsSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title={item.label}
|
||||
>
|
||||
<div className="px-4 py-3 text-sm text-[#9aa0a6]">Afficher</div>
|
||||
<SidebarNavSheetAction
|
||||
onClick={() => {
|
||||
onDisableNavLabel(item.id)
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Désactiver le libellé
|
||||
</SidebarNavSheetAction>
|
||||
</SidebarNavOptionsSheet>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -582,6 +664,7 @@ export function Sidebar({
|
||||
onSelectFolder,
|
||||
collapsed,
|
||||
folderUnreadCounts = {},
|
||||
splitView = false,
|
||||
}: SidebarProps) {
|
||||
const { openCompose } = useComposeActions()
|
||||
const [hoverExpanded, setHoverExpanded] = useState(false)
|
||||
@ -589,8 +672,11 @@ export function Sidebar({
|
||||
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const touchNav = useTouchNav()
|
||||
const isXs = useIsXs()
|
||||
|
||||
const isExpanded = !collapsed || hoverExpanded
|
||||
const isExpanded = !collapsed || (!touchNav && hoverExpanded)
|
||||
const isOverlayOpen = touchNav && !collapsed
|
||||
|
||||
const {
|
||||
folderTree,
|
||||
@ -806,7 +892,7 @@ export function Sidebar({
|
||||
}, [selectedFolder])
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (readXsMatches()) return
|
||||
if (readTouchNavMatches()) return
|
||||
if (collapsed) {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHoverExpanded(true)
|
||||
@ -819,10 +905,14 @@ export function Sidebar({
|
||||
clearTimeout(hoverTimeoutRef.current)
|
||||
hoverTimeoutRef.current = null
|
||||
}
|
||||
if (readXsMatches()) return
|
||||
if (readTouchNavMatches()) return
|
||||
setHoverExpanded(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (touchNav) setHoverExpanded(false)
|
||||
}, [touchNav, collapsed])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
@ -834,6 +924,10 @@ export function Sidebar({
|
||||
/** Inset rows from sidebar right edge (padding works with w-full; margin-right often clips under overflow-x-hidden). */
|
||||
const navRailInset = "pr-3.5"
|
||||
|
||||
/** pl-6 + demi-largeur icône nav (h-5) → axe à 34px ; picto split (size-9) centré sur cet axe. */
|
||||
const splitViewLogoIconClass = "size-9 shrink-0"
|
||||
const splitViewLogoHeaderClass = "min-h-10 pl-4 pr-3.5 pb-2"
|
||||
|
||||
/** Same row geometry collapsed / expanded / hover so icons never jump (h-8, pl-6 icon column). */
|
||||
const NavItem = ({
|
||||
item,
|
||||
@ -923,6 +1017,8 @@ export function Sidebar({
|
||||
const [subfolderName, setSubfolderName] = useState("")
|
||||
const folderRenameInputRef = useRef<HTMLInputElement>(null)
|
||||
const subfolderNameInputRef = useRef<HTMLInputElement>(null)
|
||||
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
|
||||
useSidebarTouchOptionsMenu(touchNav && isExpanded)
|
||||
|
||||
useEffect(() => {
|
||||
setRenameDraft(node.label)
|
||||
@ -936,7 +1032,7 @@ export function Sidebar({
|
||||
}
|
||||
|
||||
const rowHoverHeld =
|
||||
!isSelected && !isOver && (contextMenuOpen || menuOpen)
|
||||
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
|
||||
|
||||
const prefs = getNavItemPrefs(node.id)
|
||||
const moveTargets = useMemo(
|
||||
@ -998,14 +1094,15 @@ export function Sidebar({
|
||||
}
|
||||
|
||||
const rowClass = cn(
|
||||
"group/folderrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm transition-colors",
|
||||
"group/folderrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm transition-colors",
|
||||
isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
|
||||
isStickyBranch && "sticky border-b border-gray-200/70",
|
||||
isStickyBranch && !isSelected && !rowHoverHeld && "bg-app-canvas",
|
||||
isSelected && "bg-[#d3e3fd] font-medium text-gray-900",
|
||||
!isSelected && hasUnread && "text-gray-900",
|
||||
isOver && "bg-yellow-100 text-gray-900",
|
||||
rowHoverHeld && "bg-gray-100 text-gray-900"
|
||||
rowHoverHeld && "bg-gray-100 text-gray-900",
|
||||
touchRowClassName
|
||||
)
|
||||
const rowStyle: CSSProperties = {
|
||||
paddingLeft: 24 + depth * 16,
|
||||
@ -1015,12 +1112,14 @@ export function Sidebar({
|
||||
const overflowMenu = (
|
||||
<SidebarOverflowColumn
|
||||
unread={unread}
|
||||
menuOpen={menuOpen}
|
||||
menuOpen={menuOpen || sheetOpen}
|
||||
hoverGroup="folderrow"
|
||||
isSelected={isSelected}
|
||||
hasUnread={hasUnread}
|
||||
className={cn(!isExpanded && "hidden", "mr-[-11px]")}
|
||||
showMenuButton={!touchNav}
|
||||
>
|
||||
{!touchNav && (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@ -1116,9 +1215,115 @@ export function Sidebar({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarOverflowColumn>
|
||||
)
|
||||
|
||||
const folderOptionsSheet = touchNav && isExpanded && (
|
||||
<SidebarNavOptionsSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title={node.label}
|
||||
colorDotClass={dotClass}
|
||||
>
|
||||
<SidebarNavSheetColorPicker
|
||||
title="Couleur du dossier"
|
||||
dotClass={dotClass}
|
||||
swatches={LABEL_MENU_COLOR_SWATCHES}
|
||||
onPick={(sw) => {
|
||||
updateFolderOrLabelColor(node.id, sw)
|
||||
closeSheet()
|
||||
}}
|
||||
/>
|
||||
<SidebarNavSheetDivider />
|
||||
<SidebarNavSheetSectionLabel>Dans la liste des dossiers</SidebarNavSheetSectionLabel>
|
||||
<SidebarNavSheetCheckOption
|
||||
checked={prefs.sidebar === "show"}
|
||||
onPick={() => {
|
||||
setNavItemSidebarVisibility(node.id, "show")
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Afficher
|
||||
</SidebarNavSheetCheckOption>
|
||||
<SidebarNavSheetCheckOption
|
||||
checked={prefs.sidebar === "showUnread"}
|
||||
onPick={() => {
|
||||
setNavItemSidebarVisibility(node.id, "showUnread")
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Afficher si messages non lus
|
||||
</SidebarNavSheetCheckOption>
|
||||
<SidebarNavSheetCheckOption
|
||||
checked={prefs.sidebar === "hide"}
|
||||
onPick={() => {
|
||||
setNavItemSidebarVisibility(node.id, "hide")
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Masquer
|
||||
</SidebarNavSheetCheckOption>
|
||||
<SidebarNavSheetDivider />
|
||||
<SidebarNavSheetSectionLabel>Dans la liste des messages</SidebarNavSheetSectionLabel>
|
||||
<SidebarNavSheetCheckOption
|
||||
checked={prefs.messages === "show"}
|
||||
onPick={() => {
|
||||
setNavItemMessageVisibility(node.id, "show")
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Afficher
|
||||
</SidebarNavSheetCheckOption>
|
||||
<SidebarNavSheetCheckOption
|
||||
checked={prefs.messages === "hide"}
|
||||
onPick={() => {
|
||||
setNavItemMessageVisibility(node.id, "hide")
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Masquer
|
||||
</SidebarNavSheetCheckOption>
|
||||
<SidebarNavSheetDivider />
|
||||
<SidebarNavSheetAction
|
||||
onClick={() => {
|
||||
setRenameDraft(node.label)
|
||||
setRenameOpen(true)
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Renommer…
|
||||
</SidebarNavSheetAction>
|
||||
<SidebarNavSheetAction
|
||||
onClick={() => {
|
||||
setMoveParent("__root__")
|
||||
setMoveOpen(true)
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Déplacer…
|
||||
</SidebarNavSheetAction>
|
||||
<SidebarNavSheetAction
|
||||
onClick={() => {
|
||||
setSubfolderName("")
|
||||
setSubfolderOpen(true)
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Nouveau sous-dossier…
|
||||
</SidebarNavSheetAction>
|
||||
<SidebarNavSheetAction
|
||||
destructive
|
||||
onClick={() => {
|
||||
removeFolderOrLabelRow(node.id)
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Supprimer le dossier
|
||||
</SidebarNavSheetAction>
|
||||
</SidebarNavOptionsSheet>
|
||||
)
|
||||
|
||||
const onFolderRowDragEnter = (e: React.DragEvent) => {
|
||||
const active = navDragRef.current
|
||||
if (active?.kind === "folder" && active.id !== node.id) {
|
||||
@ -1175,12 +1380,10 @@ export function Sidebar({
|
||||
beginNavDrag(payload, rowEl)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||||
<ContextMenuTrigger asChild>
|
||||
const folderRowEl = (
|
||||
<div
|
||||
data-nav-row
|
||||
{...touchRowProps}
|
||||
onDragEnter={onFolderRowDragEnter}
|
||||
onDragOver={onFolderRowDragOver}
|
||||
onDragLeave={onFolderRowDragLeave}
|
||||
@ -1263,7 +1466,15 @@ export function Sidebar({
|
||||
</div>
|
||||
{overflowMenu}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{touchNav ? (
|
||||
folderRowEl
|
||||
) : (
|
||||
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||||
<ContextMenuTrigger asChild>{folderRowEl}</ContextMenuTrigger>
|
||||
<ContextMenuContent className={folderMenuSurface}>
|
||||
{colorSub("context")}
|
||||
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
|
||||
@ -1341,6 +1552,8 @@ export function Sidebar({
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{folderOptionsSheet}
|
||||
|
||||
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
||||
<DialogContent
|
||||
@ -1574,6 +1787,8 @@ export function Sidebar({
|
||||
const labelRenameInputRef = useRef<HTMLInputElement>(null)
|
||||
const sublabelNameInputRef = useRef<HTMLInputElement>(null)
|
||||
const canDragLabel = labelRowExpanded && !isSystemNavLabelId(item.id)
|
||||
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
|
||||
useSidebarTouchOptionsMenu(touchNav && labelRowExpanded)
|
||||
|
||||
useEffect(() => {
|
||||
setRenameDraft(item.label)
|
||||
@ -1587,7 +1802,7 @@ export function Sidebar({
|
||||
}
|
||||
|
||||
const rowHoverHeld =
|
||||
!isSelected && !isOver && (contextMenuOpen || menuOpen)
|
||||
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
|
||||
|
||||
const prefs = getNavItemPrefs(item.id)
|
||||
const labelDotClass = item.color ?? "bg-gray-400"
|
||||
@ -1643,7 +1858,7 @@ export function Sidebar({
|
||||
}
|
||||
|
||||
const rowClass = cn(
|
||||
"group/labelrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors",
|
||||
"group/labelrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors",
|
||||
navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
|
||||
isSelected
|
||||
? "bg-[#d3e3fd] text-gray-900 font-medium"
|
||||
@ -1653,7 +1868,8 @@ export function Sidebar({
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: hasUnread
|
||||
? "text-gray-900 hover:bg-gray-100"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
touchRowClassName
|
||||
)
|
||||
|
||||
const onLabelRowDragEnter = (e: React.DragEvent) => {
|
||||
@ -1717,12 +1933,14 @@ export function Sidebar({
|
||||
const overflowMenu = labelRowExpanded ? (
|
||||
<SidebarOverflowColumn
|
||||
unread={unreadCount}
|
||||
menuOpen={menuOpen}
|
||||
menuOpen={menuOpen || sheetOpen}
|
||||
hoverGroup="labelrow"
|
||||
isSelected={isSelected}
|
||||
hasUnread={hasUnread}
|
||||
className="mr-[-7px]"
|
||||
showMenuButton={!touchNav}
|
||||
>
|
||||
{!touchNav && (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@ -1809,15 +2027,110 @@ export function Sidebar({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarOverflowColumn>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||||
<ContextMenuTrigger asChild>
|
||||
const labelOptionsSheet = touchNav && labelRowExpanded && (
|
||||
<SidebarNavOptionsSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title={item.label}
|
||||
colorDotClass={labelDotClass}
|
||||
>
|
||||
<SidebarNavSheetColorPicker
|
||||
title="Couleur du libellé"
|
||||
dotClass={labelDotClass}
|
||||
swatches={LABEL_MENU_COLOR_SWATCHES}
|
||||
onPick={(sw) => {
|
||||
updateFolderOrLabelColor(item.id, sw)
|
||||
closeSheet()
|
||||
}}
|
||||
/>
|
||||
<SidebarNavSheetDivider />
|
||||
<SidebarNavSheetSectionLabel>Dans la liste des libellés</SidebarNavSheetSectionLabel>
|
||||
<SidebarNavSheetCheckOption
|
||||
checked={prefs.sidebar === "show"}
|
||||
onPick={() => {
|
||||
setNavItemSidebarVisibility(item.id, "show")
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Afficher
|
||||
</SidebarNavSheetCheckOption>
|
||||
<SidebarNavSheetCheckOption
|
||||
checked={prefs.sidebar === "showUnread"}
|
||||
onPick={() => {
|
||||
setNavItemSidebarVisibility(item.id, "showUnread")
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Afficher si messages non lus
|
||||
</SidebarNavSheetCheckOption>
|
||||
<SidebarNavSheetCheckOption
|
||||
checked={prefs.sidebar === "hide"}
|
||||
onPick={() => {
|
||||
setNavItemSidebarVisibility(item.id, "hide")
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Masquer
|
||||
</SidebarNavSheetCheckOption>
|
||||
<SidebarNavSheetDivider />
|
||||
<SidebarNavSheetSectionLabel>Dans la liste des messages</SidebarNavSheetSectionLabel>
|
||||
<SidebarNavSheetCheckOption
|
||||
checked={prefs.messages === "show"}
|
||||
onPick={() => {
|
||||
setNavItemMessageVisibility(item.id, "show")
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Afficher
|
||||
</SidebarNavSheetCheckOption>
|
||||
<SidebarNavSheetCheckOption
|
||||
checked={prefs.messages === "hide"}
|
||||
onPick={() => {
|
||||
setNavItemMessageVisibility(item.id, "hide")
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Masquer
|
||||
</SidebarNavSheetCheckOption>
|
||||
<SidebarNavSheetDivider />
|
||||
<SidebarNavSheetAction
|
||||
onClick={() => {
|
||||
setRenameDraft(item.label)
|
||||
setRenameOpen(true)
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Renommer…
|
||||
</SidebarNavSheetAction>
|
||||
<SidebarNavSheetAction
|
||||
destructive
|
||||
onClick={() => {
|
||||
removeFolderOrLabelRow(item.id)
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Supprimer le libellé
|
||||
</SidebarNavSheetAction>
|
||||
<SidebarNavSheetAction
|
||||
onClick={() => {
|
||||
setSublabelName("")
|
||||
setSublabelOpen(true)
|
||||
closeSheet()
|
||||
}}
|
||||
>
|
||||
Ajouter un sous-libellé
|
||||
</SidebarNavSheetAction>
|
||||
</SidebarNavOptionsSheet>
|
||||
)
|
||||
|
||||
const labelRowEl = (
|
||||
<div
|
||||
data-nav-row
|
||||
{...touchRowProps}
|
||||
onDragEnter={onLabelRowDragEnter}
|
||||
onDragOver={onLabelRowDragOver}
|
||||
onDragLeave={onLabelRowDragLeave}
|
||||
@ -1860,7 +2173,15 @@ export function Sidebar({
|
||||
</div>
|
||||
{overflowMenu}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{touchNav ? (
|
||||
labelRowEl
|
||||
) : (
|
||||
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||||
<ContextMenuTrigger asChild>{labelRowEl}</ContextMenuTrigger>
|
||||
<ContextMenuContent className={labelMenuSurface}>
|
||||
{colorSub("context")}
|
||||
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
|
||||
@ -1929,6 +2250,8 @@ export function Sidebar({
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{labelOptionsSheet}
|
||||
|
||||
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
||||
<DialogContent
|
||||
@ -2027,29 +2350,61 @@ export function Sidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
data-sidebar
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden bg-app-canvas transition-[width,transform] duration-200 z-40",
|
||||
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden bg-app-canvas transition-[width,transform] duration-200 z-40 select-none",
|
||||
isExpanded ? "w-60" : "w-[68px]",
|
||||
hoverExpanded && "shadow-xl border-r border-gray-200",
|
||||
!collapsed && "max-sm:z-50 max-sm:shadow-xl max-sm:border-r max-sm:border-gray-200",
|
||||
collapsed && "max-sm:-translate-x-full max-sm:pointer-events-none"
|
||||
!touchNav && hoverExpanded && "shadow-xl border-r border-gray-200",
|
||||
isOverlayOpen && "z-50 shadow-xl border-r border-gray-200",
|
||||
collapsed && isXs && "-translate-x-full pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between px-4 pt-4 pb-4 sm:hidden">
|
||||
<UltiMailLogo className="min-h-8" />
|
||||
<Button variant="ghost" size="icon" className="size-9 shrink-0 text-gray-600" aria-label="Réglages">
|
||||
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center bg-app-canvas",
|
||||
splitView
|
||||
? cn(
|
||||
splitViewLogoHeaderClass,
|
||||
isExpanded ? "justify-between" : "justify-start"
|
||||
)
|
||||
: "justify-between px-4 pt-4 pb-4 sm:hidden"
|
||||
)}
|
||||
>
|
||||
{splitView && !isExpanded ? (
|
||||
<UltiMailLogo variant="mark" className={splitViewLogoIconClass} />
|
||||
) : (
|
||||
<>
|
||||
<UltiMailLogo
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
splitView
|
||||
? "max-w-[140px] gap-4 [&_img]:size-9"
|
||||
: "min-h-8"
|
||||
)}
|
||||
/>
|
||||
{(splitView || touchNav) && isExpanded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 shrink-0 text-gray-600"
|
||||
aria-label="Réglages"
|
||||
>
|
||||
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"hidden shrink-0 bg-app-canvas z-10 pt-1 pb-3 pl-2 sm:flex",
|
||||
isExpanded ? "pr-3.5" : "pr-2"
|
||||
isExpanded ? "pr-3.5" : "pr-2",
|
||||
splitView && "!hidden"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@ -2097,6 +2452,7 @@ export function Sidebar({
|
||||
isExpanded={isExpanded}
|
||||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||||
onSelectFolder={onSelectFolder}
|
||||
touchNav={touchNav}
|
||||
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
|
||||
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
|
||||
/>
|
||||
@ -2151,6 +2507,7 @@ export function Sidebar({
|
||||
isExpanded={isExpanded}
|
||||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||||
onSelectFolder={onSelectFolder}
|
||||
touchNav={touchNav}
|
||||
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
|
||||
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
|
||||
/>
|
||||
@ -2185,6 +2542,7 @@ export function Sidebar({
|
||||
isExpanded={isExpanded}
|
||||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||||
onSelectFolder={onSelectFolder}
|
||||
touchNav={touchNav}
|
||||
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
|
||||
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
|
||||
variant="hidden"
|
||||
@ -2198,7 +2556,7 @@ export function Sidebar({
|
||||
{/* Dossiers (hiérarchie : chevron = replier / déplier uniquement) */}
|
||||
<div className="mt-3 pt-1">
|
||||
<div
|
||||
className="sticky top-0 z-31 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
|
||||
className="sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
|
||||
title={!isExpanded ? "Dossiers" : undefined}
|
||||
>
|
||||
<Folder className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
|
||||
@ -2234,7 +2592,7 @@ export function Sidebar({
|
||||
{/* Labels */}
|
||||
<div className="mt-3 pt-1">
|
||||
<div
|
||||
className="sticky top-0 z-31 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
|
||||
className="sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
|
||||
title={!isExpanded ? "Libellés" : undefined}
|
||||
>
|
||||
<Tag className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
|
||||
@ -2270,10 +2628,12 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Sortbot */}
|
||||
<div className={cn(
|
||||
"z-30 mt-auto bg-app-canvas pt-2",
|
||||
"max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-32 mt-auto bg-app-canvas pt-2",
|
||||
"max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={!isExpanded ? "Sortbot" : undefined}
|
||||
|
||||
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 {
|
||||
const locale = fr
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
@ -277,11 +298,7 @@ export function formatInvitationTimeChip(start: Date, end: Date): string {
|
||||
: format(start, "EEE d MMM yyyy", { locale })
|
||||
}
|
||||
|
||||
const tzShort =
|
||||
new Intl.DateTimeFormat(undefined, { timeZone: tz, timeZoneName: "short" })
|
||||
.formatToParts(start)
|
||||
.find((p) => p.type === "timeZoneName")?.value ?? ""
|
||||
|
||||
const tzShort = formatStableUtcOffsetLabel(start, tz)
|
||||
const range = `${startLabel} – ${endLabel}`
|
||||
return tzShort ? `${day} • ${range} (${tzShort})` : `${day} • ${range}`
|
||||
}
|
||||
|
||||
@ -195,6 +195,7 @@ export type ComposeOpenPreset = Partial<
|
||||
| "focusToOnMount"
|
||||
| "focusBodyOnMount"
|
||||
| "focusSubjectOnMount"
|
||||
| "maximized"
|
||||
| "threading"
|
||||
| "showCc"
|
||||
| "showBcc"
|
||||
@ -340,6 +341,7 @@ function mergeComposeFromPreset(
|
||||
focusBodyOnMount: preset.focusBodyOnMount ?? base.focusBodyOnMount,
|
||||
focusSubjectOnMount:
|
||||
preset.focusSubjectOnMount ?? base.focusSubjectOnMount,
|
||||
maximized: preset.maximized ?? base.maximized,
|
||||
threading: preset.threading !== undefined ? preset.threading : base.threading,
|
||||
showCc: preset.showCc ?? base.showCc,
|
||||
showBcc: preset.showBcc ?? base.showBcc,
|
||||
|
||||
@ -32,6 +32,7 @@ export interface Email {
|
||||
id: string
|
||||
sender: string
|
||||
senderEmail?: string
|
||||
/** @deprecated Utiliser `getThreadMessageCount(email)` — ancien compteur participants. */
|
||||
participantCount?: number
|
||||
subject: string
|
||||
preview: string
|
||||
@ -63,6 +64,13 @@ export interface Email {
|
||||
snoozeWakeAt?: string
|
||||
}
|
||||
|
||||
/** Messages du fil : message principal + entrées `conversation`. */
|
||||
export function getThreadMessageCount(
|
||||
email: Pick<Email, "conversation">
|
||||
): number {
|
||||
return 1 + (email.conversation?.length ?? 0)
|
||||
}
|
||||
|
||||
export const emails: Email[] = [
|
||||
...demoCalendarInvitationEmails,
|
||||
{
|
||||
|
||||
@ -4,6 +4,8 @@ import type {
|
||||
ComposeOpenPreset,
|
||||
ThreadComposeKind,
|
||||
} from "@/lib/compose-context"
|
||||
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
|
||||
import { readXsMatches } from "@/hooks/use-xs"
|
||||
import { DEFAULT_IDENTITIES, SIGNATURES } from "@/lib/compose-context"
|
||||
import { formatMailDetailDate } from "@/lib/mail-date"
|
||||
import { cleanSenderName } from "@/lib/sender-display"
|
||||
@ -180,6 +182,24 @@ function forwardBodyHtml(email: Email): string {
|
||||
return `<p></p>${header}${forwardConversationHtml(email)}`
|
||||
}
|
||||
|
||||
/** Tablette / tactile : composition plein écran (dock maximisé) au lieu 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`.
|
||||
*/
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Eliott Guillaumin
|
||||
* All rights reserved.
|
||||
*/
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
allowedDevOrigins: ['192.168.0.20', '127.0.0.1', 'localhost', '100.120.4.66'],
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --hostname 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user