From 1fc4de1873f19d5225370889ba8a65eb1b1a28ea Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Mon, 18 May 2026 00:17:51 +0200 Subject: [PATCH] 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. --- app/globals.css | 42 + app/layout.tsx | 15 +- app/mail/mail-app-shell.tsx | 67 +- .../gmail/calendar-invitation-preview.tsx | 12 +- components/gmail/compose-modal.tsx | 5 +- components/gmail/email-list.tsx | 1008 +++++++++++------ components/gmail/email-view.tsx | 85 +- components/gmail/header.tsx | 27 +- .../gmail/invitation-time-chip-text.tsx | 20 + components/gmail/mail-search-bar.tsx | 43 + .../gmail/sidebar-nav-options-sheet.tsx | 161 +++ components/gmail/sidebar.tsx | 598 ++++++++-- components/gmail/use-sidebar-touch-options.ts | 40 + hooks/use-long-press.ts | 52 + hooks/use-mail-split-view.ts | 47 + hooks/use-touch-nav.ts | 49 + lib/calendar-invitation.ts | 27 +- lib/compose-context.tsx | 2 + lib/email-data.ts | 8 + lib/thread-compose-preset.ts | 20 + next-env.d.ts | 2 +- next.config.mjs | 5 + package.json | 2 +- tsconfig.tsbuildinfo | 2 +- 24 files changed, 1790 insertions(+), 549 deletions(-) create mode 100644 components/gmail/invitation-time-chip-text.tsx create mode 100644 components/gmail/mail-search-bar.tsx create mode 100644 components/gmail/sidebar-nav-options-sheet.tsx create mode 100644 components/gmail/use-sidebar-touch-options.ts create mode 100644 hooks/use-long-press.ts create mode 100644 hooks/use-mail-split-view.ts create mode 100644 hooks/use-touch-nav.ts diff --git a/app/globals.css b/app/globals.css index 5157882..8bff66a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx index bb064df..c250026 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - + + {children} {process.env.NODE_ENV === 'production' && } diff --git a/app/mail/mail-app-shell.tsx b/app/mail/mail-app-shell.tsx index 1f5b952..e3b10e5 100644 --- a/app/mail/mail-app-shell.tsx +++ b/app/mail/mail-app-shell.tsx @@ -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() { }) } > -
-
-
setSidebarCollapsed(!sidebarCollapsed)} - /> -
+
+ {!splitView ? ( +
+
setSidebarCollapsed(!sidebarCollapsed)} + /> +
+ ) : null}
- {!sidebarCollapsed && ( + {!sidebarCollapsed && touchNav && (
) @@ -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 ( +
diff --git a/components/gmail/calendar-invitation-preview.tsx b/components/gmail/calendar-invitation-preview.tsx index 3fec3a8..c865352 100644 --- a/components/gmail/calendar-invitation-preview.tsx +++ b/components/gmail/calendar-invitation-preview.tsx @@ -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({
- {timeChip} +

{invitation.summary} diff --git a/components/gmail/compose-modal.tsx b/components/gmail/compose-modal.tsx index ecdbd37..29ab21a 100644 --- a/components/gmail/compose-modal.tsx +++ b/components/gmail/compose-modal.tsx @@ -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]" ) )} diff --git a/components/gmail/email-list.tsx b/components/gmail/email-list.tsx index c0221de..8a3e1ed 100644 --- a/components/gmail/email-list.tsx +++ b/components/gmail/email-list.tsx @@ -35,6 +35,7 @@ import { Trash2, Mail, MailOpen, + Menu, Clock, ListTodo, FolderInput, @@ -97,7 +98,12 @@ import { MailLabelPillStrip, mailLabelShouldShowInListStrip, } from "@/components/gmail/mail-label-pills" -import { emails, type Email, type EmailAttachment } from "@/lib/email-data" +import { + emails, + getThreadMessageCount, + type Email, + type EmailAttachment, +} from "@/lib/email-data" import { useScheduledMail } from "@/lib/scheduled-mail-context" import { useMailStore } from "@/lib/stores/mail-store" import { useScheduledStore } from "@/lib/stores/scheduled-store" @@ -129,6 +135,7 @@ import { type MoveTarget, } from "@/components/gmail/move-to-menu-items" import { EmailView } from "./email-view" +import { MailSearchBar } from "@/components/gmail/mail-search-bar" import { MailDateText } from "@/components/gmail/mail-date-text" import { formatMailDetailDate } from "@/lib/mail-date" import { buildListMailIndex } from "./email-list-row" @@ -545,6 +552,9 @@ interface EmailListProps { /** Page de liste (1-based), depuis l’URL. */ listPage: number openMailId: string | null + /** md+ split pane: list left, reading pane right (tablet landscape or user setting). */ + splitView?: boolean + onToggleSidebar?: () => void onMailRouteNavigate: (patch: Partial) => void onSelectFolder?: (folder: string) => void onFolderUnreadCountsChange?: (counts: Record) => void @@ -570,14 +580,18 @@ export function EmailList({ inboxTab, listPage, openMailId, + splitView = false, + onToggleSidebar, onMailRouteNavigate, onSelectFolder, onFolderUnreadCountsChange, }: EmailListProps) { - const isViewMode = openMailId !== null + const isViewMode = openMailId !== null && !splitView + const showSplitReadingPane = splitView && openMailId !== null const { savedThreadReplyDrafts } = useComposeDrafts() const { + openCompose, openComposeWithInitial, closeAllInlineComposes, pruneInlineComposesToOpenThread, @@ -1641,9 +1655,17 @@ export function EmailList({ const navigateToMail = useCallback( (id: string | null) => { + if (id && splitView) { + const idx = filteredEmails.findIndex((e) => e.id === id) + if (idx >= 0) { + const page = Math.floor(idx / LIST_PAGE_SIZE) + 1 + onMailRouteNavigate({ mailId: id, page }) + return + } + } onMailRouteNavigate({ mailId: id }) }, - [onMailRouteNavigate] + [splitView, filteredEmails, onMailRouteNavigate] ) useEffect(() => { @@ -1654,7 +1676,30 @@ export function EmailList({ } }, [openMailId, allEmails, navigateToMail]) - const goBack = useCallback(() => navigateToMail(null), [navigateToMail]) + const pickAdjacentMailId = useCallback( + (currentId: string) => { + const idx = filteredEmails.findIndex((e) => e.id === currentId) + if (idx < 0) return filteredEmails[0]?.id ?? null + if (idx < filteredEmails.length - 1) return filteredEmails[idx + 1]!.id + if (idx > 0) return filteredEmails[idx - 1]!.id + return null + }, + [filteredEmails] + ) + + const leaveReadingPane = useCallback(() => { + if (!splitView) { + navigateToMail(null) + return + } + if (!openMailId) return + navigateToMail(pickAdjacentMailId(openMailId)) + }, [splitView, openMailId, navigateToMail, pickAdjacentMailId]) + + const goBack = useCallback(() => { + if (splitView) leaveReadingPane() + else navigateToMail(null) + }, [splitView, leaveReadingPane, navigateToMail]) const closeViewIfShowingEmail = useCallback( (emailId: string) => { @@ -1775,30 +1820,42 @@ export function EmailList({ : openEmail.read }, [openEmail, readOverrides]) + const afterSingleMessageRemoved = useCallback( + (removedId: string) => { + if (splitView) navigateToMail(pickAdjacentMailId(removedId)) + else navigateToMail(null) + }, + [splitView, navigateToMail, pickAdjacentMailId] + ) + const singleArchive = useCallback(() => { if (!openMailId) return - mailActions.hideEmail(openMailId) - goBack() - }, [openMailId, goBack, mailActions]) + const id = openMailId + mailActions.hideEmail(id) + afterSingleMessageRemoved(id) + }, [openMailId, afterSingleMessageRemoved, mailActions]) const singleDelete = useCallback(() => { if (!openMailId) return - mailActions.hideEmail(openMailId) - goBack() - }, [openMailId, goBack, mailActions]) + const id = openMailId + mailActions.hideEmail(id) + afterSingleMessageRemoved(id) + }, [openMailId, afterSingleMessageRemoved, mailActions]) const singleSpam = useCallback(() => { if (!openMailId) return - mailActions.hideEmail(openMailId) - goBack() - }, [openMailId, goBack, mailActions]) + const id = openMailId + mailActions.hideEmail(id) + afterSingleMessageRemoved(id) + }, [openMailId, afterSingleMessageRemoved, mailActions]) const singleNotSpam = useCallback(() => { if (!openMailId) return - mailActions.markNotSpam(openMailId) + const id = openMailId + mailActions.markNotSpam(id) onSelectFolder?.("inbox") - goBack() - }, [openMailId, goBack, onSelectFolder, mailActions]) + afterSingleMessageRemoved(id) + }, [openMailId, afterSingleMessageRemoved, onSelectFolder, mailActions]) const singleToggleRead = useCallback(() => { if (!openMailId) return @@ -1811,12 +1868,38 @@ export function EmailList({ moveEmailsToTarget([openMailId], targetId) const isSystemHide = ["sent", "drafts", "spam", "trash"].includes(targetId) if (isSystemHide || targetId !== "inbox") { - goBack() + afterSingleMessageRemoved(openMailId) } }, - [openMailId, goBack, moveEmailsToTarget] + [openMailId, afterSingleMessageRemoved, moveEmailsToTarget] ) + useEffect(() => { + if (!splitView) return + const firstId = filteredEmails[0]?.id ?? null + if (!openMailId) { + if (firstId) navigateToMail(firstId) + return + } + const raw = allEmails.find((e) => e.id === openMailId) + if (raw?.labels?.includes("scheduled")) { + navigateToMail(firstId) + return + } + if (!filteredEmails.some((e) => e.id === openMailId)) { + navigateToMail(firstId) + } + }, [ + splitView, + selectedFolder, + inboxTab, + listPage, + filteredEmails, + openMailId, + navigateToMail, + allEmails, + ]) + const handleNavigateToLabel = useCallback( (label: string) => { const folderId = @@ -1831,6 +1914,22 @@ export function EmailList({ }, [folderUnreadCounts, onFolderUnreadCountsChange]) const listRowsDep = listEmails.map((e) => e.id).join(",") + useLayoutEffect(() => { + if (!splitView || !openMailId) return + const scrollActiveRowIntoView = () => { + const root = listViewportRef.current + if (!root) return + const row = root.querySelector( + `[data-email-row-id="${openMailId}"]` + ) + if (!row) return + row.scrollIntoView({ block: "nearest", behavior: "smooth" }) + } + scrollActiveRowIntoView() + const frame = requestAnimationFrame(scrollActiveRowIntoView) + return () => cancelAnimationFrame(frame) + }, [splitView, openMailId, listPage, listRowsDep]) + useEffect(() => { const root = listViewportRef.current if (!root) return @@ -1850,21 +1949,349 @@ export function EmailList({ return () => obs.disconnect() }, [listRowsDep, markEmailSeen]) - // --- keyboard shortcuts for view mode --- + // --- keyboard shortcuts for view / split reading pane --- useEffect(() => { - if (!isViewMode) return + if (!isViewMode && !showSplitReadingPane) return const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") { goBack(); return } - if (e.key === "ArrowLeft" || e.key === "k") { goToPrev(); return } - if (e.key === "ArrowRight" || e.key === "j") { goToNext(); return } + if (e.key === "Escape") { + if (!splitView) goBack() + return + } + if (e.key === "ArrowLeft" || e.key === "k") { + goToPrev() + return + } + if (e.key === "ArrowRight" || e.key === "j") { + goToNext() + return + } } window.addEventListener("keydown", handler) return () => window.removeEventListener("keydown", handler) - }, [isViewMode, goBack, goToPrev, goToNext]) + }, [isViewMode, showSplitReadingPane, splitView, goBack, goToPrev, goToNext]) const dropdownSurfaceClass = "min-w-[220px] 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-sub-trigger]]:gap-3 [&_[data-slot=dropdown-menu-sub-trigger]]:rounded-none [&_[data-slot=dropdown-menu-sub-trigger]]:px-3 [&_[data-slot=dropdown-menu-sub-trigger]]:py-2 [&_[data-slot=dropdown-menu-sub-trigger]]:text-sm [&_[data-slot=dropdown-menu-sub-trigger]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-sub-content]]:min-w-[200px] [&_[data-slot=dropdown-menu-sub-content]]:rounded-lg [&_[data-slot=dropdown-menu-sub-content]]:border [&_[data-slot=dropdown-menu-sub-content]]:border-[#dadce0] [&_[data-slot=dropdown-menu-sub-content]]:bg-white [&_[data-slot=dropdown-menu-sub-content]]:p-0 [&_[data-slot=dropdown-menu-sub-content]]:py-1 [&_[data-slot=dropdown-menu-sub-content]]:shadow-lg [&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1 [&_[data-slot=dropdown-menu-separator]]:bg-[#eceff1]" + const listToolbarMode = splitView || !isViewMode + /** xs + split : icône (+ point si non lus) ; libellé uniquement sur l’onglet actif. */ + const compactInboxTabs = isXs || splitView + + const openMailToolbar = (showBack: boolean) => ( + + {showBack ? ( + + + + + + Retour à la boîte de réception + + + ) : null} + +
+ {openEmail?.spam === true ? ( + <> +
+ + +
+ + + +
+ + + + + + Archiver + + +
+ + + +
+ + + + + + {viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"} + + + + + + + + + + + +
+ + ) : ( + <> +
+ + + + + + Archiver + + + + + + + + Signaler comme spam + + + + + + + + Supprimer + + +
+ + + +
+ + + + + + {viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"} + + + + + + + + + + + +
+ + )} +
+
+ ) + + const mailPaginationControls = (mode: "list" | "view") => ( +
+ {filteredEmails.length === 0 ? ( + Aucun résultat + ) : mode === "view" ? ( + + {openMailIndex >= 0 ? openMailIndex + 1 : "–"} sur {filteredEmails.length} + + ) : ( + + {(listPage - 1) * LIST_PAGE_SIZE + 1}– + {Math.min(listPage * LIST_PAGE_SIZE, filteredEmails.length)} sur{" "} + {filteredEmails.length} + {totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null} + + )} + + + + + + {mode === "view" ? "Plus récent" : "Page précédente"} + + + + + + + + {mode === "view" ? "Plus ancien" : "Page suivante"} + + +
+ ) + const mainScrollClass = "min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded-b-2xl border-0 bg-white shadow-none outline-none " + "[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " + @@ -2026,227 +2453,45 @@ export function EmailList({

)} - {/* Toolbar — relative: scroll lives in sibling below */} -
- - {isViewMode ? ( - /* ── VIEW MODE TOOLBAR ── */ - - - +
+
+ {splitView ? ( +
+ {onToggleSidebar ? ( - - - Retour à la boîte de réception - - - -
- {openEmail?.spam === true ? ( - <> -
- - -
- - - -
- - - - - Archiver - -
- - - -
- - - - - - {viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"} - - - - - - - - - - - -
- - ) : ( - <> -
- - - - - Archiver - - - - - - Signaler comme spam - - - - - - Supprimer - -
- - - -
- - - - - - {viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"} - - - - - - - - - - - -
- - )} + ) : null} +
- + ) : null} + + {/* Toolbar — relative: scroll lives in sibling below */} +
+ + {!splitView && isViewMode ? ( + openMailToolbar(true) ) : ( /* ── LIST MODE TOOLBAR (original) ── */ <> @@ -2566,90 +2811,25 @@ export function EmailList({
- {/* Pagination — liste (pages) ou vue message (position dans le filtre) */} -
- {filteredEmails.length === 0 ? ( - Aucun résultat - ) : isViewMode ? ( - - {openMailIndex >= 0 ? openMailIndex + 1 : "–"} sur {filteredEmails.length} - - ) : ( - - {(listPage - 1) * LIST_PAGE_SIZE + 1}– - {Math.min(listPage * LIST_PAGE_SIZE, filteredEmails.length)} sur{" "} - {filteredEmails.length} - {totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null} - - )} - - - - - - {isViewMode ? "Plus récent" : "Page précédente"} - - - - - - - - {isViewMode ? "Plus ancien" : "Page suivante"} - - -
+ {listToolbarMode ? mailPaginationControls("list") : null} + {!splitView && !listToolbarMode ? mailPaginationControls("view") : null}
{selectedFolder === "inbox" && (
- {!isViewMode && ( + {listToolbarMode && (
{inboxTabBarItems.map((tab) => { const inboxTabNorm = normalizeInboxTabSegment(inboxTab) @@ -2665,16 +2845,58 @@ export function EmailList({ ) })} @@ -2744,13 +2968,18 @@ export function EmailList({
)} +
- {!isViewMode && ( -
@@ -2767,10 +2996,11 @@ export function EmailList({
- {isViewMode && openEmail ? ( + {!splitView && isViewMode && openEmail ? ( /* ── EMAIL VIEW ── */
startRowDrag(email.id, e)} onClick={() => { @@ -2928,19 +3163,25 @@ export function EmailList({ handleRowActivate(email) }} className={cn( - "group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-[50ms] ease-out md:flex md:items-start md:gap-2 md:px-2 md:py-1.5", - isSelected - ? "bg-[#e8f0fe]" - : isRead - ? "bg-[#f5f5f5]" - : "bg-white", - "hover:z-1 hover:shadow-[inset_1px_0_0_#d2d5da,inset_-1px_0_0_#d2d5da,0_4px_10px_-3px_rgba(60,64,67,.16),0_2px_5px_0_rgba(60,64,67,.09)]" + "group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-[50ms] ease-out", + !splitView && + "md:flex md:items-start md:gap-2 md:px-2 md:py-1.5", + isSplitActiveRow + ? "z-[1] bg-[#e8f0fe] shadow-[inset_3px_0_0_0_#0b57d0]" + : isSelected + ? "bg-[#e8f0fe]" + : isRead + ? "bg-[#f5f5f5]" + : "bg-white", + !isSplitActiveRow && + "hover:z-1 hover:shadow-[inset_1px_0_0_#d2d5da,inset_-1px_0_0_#d2d5da,0_4px_10px_-3px_rgba(60,64,67,.16),0_2px_5px_0_rgba(60,64,67,.09)]" )} > {/* Compact < md */}
)} - {email.participantCount != null && email.participantCount > 1 && ( + {threadMessageCount > 1 && ( - {email.participantCount} + {threadMessageCount} )}
@@ -3149,7 +3391,12 @@ export function EmailList({
{/* Desktop >= md */} -
+
{isScheduled ? ( )} - {email.participantCount && email.participantCount > 1 && ( - {email.participantCount} + {threadMessageCount > 1 && ( + + {threadMessageCount} + )}
-
+
)} - {!isViewMode ? ( -
+
+
+ {listToolbarMode ? ( +
) : null}
+ + {splitView ? ( + + ) : null} +
+ {splitView ? ( +
+ {openEmail ? ( + <> +
+ {openMailToolbar(false)} +
+ {mailPaginationControls("view")} +
+
+ { + if (LABEL_PICKER_EXCLUDE.has(lab)) return true + return mailLabelShouldShowInListStrip( + lab, + sidebarNav.emailLabelToSidebarFolderId, + sidebarNav.getNavItemPrefs, + sidebarNav.labelRows + ) + }} + /> +
+ + ) : ( + + + + + + + Aucun message sélectionné + + + Choisissez un message dans la liste ou ouvrez une boîte contenant des messages. + + + + )} +
+ ) : null}
+
) } diff --git a/components/gmail/email-view.tsx b/components/gmail/email-view.tsx index cd0f11e..0789704 100644 --- a/components/gmail/email-view.tsx +++ b/components/gmail/email-view.tsx @@ -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 = { 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)}
-
+
{name} @@ -513,7 +529,7 @@ function ExpandedMessage({
)} -
+
0 ? "pb-0" : "pb-4" )} + data-selectable-text >
@@ -806,13 +823,15 @@ export function EmailView({ const savedThreadDraft = savedThreadReplyDrafts[email.id] const hasInlineForThread = Boolean(inlineCompose) + const showReplyForwardBar = !inlineCompose - const threadComposeFooterRef = useRef(null) + const previewScrollRef = useRef(null) + const threadComposeAnchorRef = useRef(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 ( -
+
+
{/* Subject header */}
@@ -968,10 +994,13 @@ export function EmailView({ onPrintConversation={() => openConversationPrint(email)} /> - {/* Réponse / transfert : flux normal, juste sous le dernier message */} -
- {showReplyForwardBar ? ( -
+ {showReplyForwardBar ? ( +
-
- ) : null} + ) : null} - {inlineCompose ? ( -
+ {inlineCompose ? ( +
- ) : null} + ) : null} +
diff --git a/components/gmail/header.tsx b/components/gmail/header.tsx index 7d87bea..8dfae76 100644 --- a/components/gmail/header.tsx +++ b/components/gmail/header.tsx @@ -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 `
` 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(null) @@ -82,21 +85,13 @@ export function Header({ > -
-
-
- -
- - + {!hideSearch ? ( +
+
-
+ ) : ( +
+ )}
{sidebarCollapsed && ( diff --git a/components/gmail/invitation-time-chip-text.tsx b/components/gmail/invitation-time-chip-text.tsx new file mode 100644 index 0000000..9b9b437 --- /dev/null +++ b/components/gmail/invitation-time-chip-text.tsx @@ -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 {text} +} diff --git a/components/gmail/mail-search-bar.tsx b/components/gmail/mail-search-bar.tsx new file mode 100644 index 0000000..0409af3 --- /dev/null +++ b/components/gmail/mail-search-bar.tsx @@ -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 ( +
+
+ +
+ + +
+ ) +} diff --git a/components/gmail/sidebar-nav-options-sheet.tsx b/components/gmail/sidebar-nav-options-sheet.tsx new file mode 100644 index 0000000..d92b241 --- /dev/null +++ b/components/gmail/sidebar-nav-options-sheet.tsx @@ -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 ( + + ) +} + +export function SidebarNavSheetSectionLabel({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +export function SidebarNavSheetDivider() { + return
+} + +export function SidebarNavSheetCheckOption({ + checked, + onPick, + children, +}: { + checked: boolean + onPick: () => void + children: ReactNode +}) { + return ( + + ) +} + +export function SidebarNavSheetColorPicker({ + title, + dotClass, + swatches, + onPick, +}: { + title: string + dotClass: string + swatches: readonly string[] + onPick: (swatch: string) => void +}) { + return ( +
+
+ + + + {title} +
+
+ {swatches.map((sw) => ( +
+
+ ) +} + +export function SidebarNavOptionsSheet({ + open, + onOpenChange, + title, + colorDotClass, + children, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + colorDotClass?: string + children: ReactNode +}) { + return ( + + +
+ {colorDotClass ? ( + + + + ) : null} + + {title} + + + + +
+
{children}
+
+
+ ) +} diff --git a/components/gmail/sidebar.tsx b/components/gmail/sidebar.tsx index 647e0d9..9b08633 100644 --- a/components/gmail/sidebar.tsx +++ b/components/gmail/sidebar.tsx @@ -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 + /** 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" > @@ -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 ( +
+ + {formatCount(unread)} + +
+ ) + } + 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 ( -
- - - - - - - 0 && ( + + {formatCount(unreadCount)} + + )} +
+ + {!touchNav && ( + + + + + + { + onEnableNavLabel(item.id) + setMenuOpen(false) + }} + > + Réactiver le libellé + + + + )} +
+ {touchNav && ( + + { onEnableNavLabel(item.id) - setMenuOpen(false) + closeSheet() }} > Réactiver le libellé - - - -
+ + + )} + ) } return ( + <>
- - - - Afficher - - { - onDisableNavLabel(item.id) - setMenuOpen(false) - }} - > - Désactiver le libellé - - - + {!touchNav && ( + + + + + + + Afficher + + { + onDisableNavLabel(item.id) + setMenuOpen(false) + }} + > + Désactiver le libellé + + + + )} )}
+ {touchNav && showCategoryMenu && ( + +
Afficher
+ { + onDisableNavLabel(item.id) + closeSheet() + }} + > + Désactiver le libellé + +
+ )} + ) } @@ -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>(() => new Set()) const hoverTimeoutRef = useRef(null) const sidebarRef = useRef(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(null) const subfolderNameInputRef = useRef(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 = ( + {!touchNav && ( +
+ {splitView && !isExpanded ? ( + + ) : ( + <> + + {(splitView || touchNav) && isExpanded && ( + + )} + + )}