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 && ( + + )} + + )}