diff --git a/app/[locale]/pro/page.tsx b/app/[locale]/pro/page.tsx index 9488ab9d..b8a9aa95 100644 --- a/app/[locale]/pro/page.tsx +++ b/app/[locale]/pro/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState, type ComponentType } from "react"; +import { useEffect, useMemo, useState, type ComponentType, type DragEvent } from "react"; import { useTranslations } from "next-intl"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; @@ -11,8 +11,8 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { EmbeddedContext } from "@/hooks/use-is-embedded"; -import { ProTabBar } from "@/components/pro/pro-tab-bar"; -import { useProTabStore, type ProTabKind } from "@/stores/pro-tab-store"; +import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar"; +import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store"; import { cn } from "@/lib/utils"; import MailPage from "@/app/[locale]/page"; @@ -31,6 +31,69 @@ const APP_TAB_COMPONENTS: Partial> = { settings: SettingsPage, }; +type DropTarget = 'left' | 'right' | 'top' | 'bottom' | null; + +function renderTabBody(tab: ProTab): React.ReactNode { + if (tab.kind === 'compose' && tab.composeData) { + return ; + } + if (tab.kind === 'email' && tab.emailData) { + return ; + } + const Component = APP_TAB_COMPONENTS[tab.kind]; + return Component ? : null; +} + +interface PaneProps { + paneId: ProPaneId; + tabs: ProTab[]; + activeTabId: string | null; + loadedTabIds: string[]; + allTabs: ProTab[]; + onActivate: (id: string) => void; + onClose: (id: string) => void; + onDragStateChange: (dragging: boolean) => void; + onPaneFocus: (paneId: ProPaneId) => void; + isFocused: boolean; +} + +function Pane({ + paneId, tabs, activeTabId, loadedTabIds, allTabs, + onActivate, onClose, onDragStateChange, onPaneFocus, isFocused, +}: PaneProps) { + return ( +
{ if (!isFocused) onPaneFocus(paneId); }} + > + +
+ {allTabs + .filter((tab) => tab.paneId === paneId && loadedTabIds.includes(tab.id)) + .map((tab) => { + const isActive = tab.id === activeTabId; + return ( +
+ {renderTabBody(tab)} +
+ ); + })} +
+
+ ); +} + export default function ProHome() { const t = useTranslations(); const { isMobile, isTablet, isDesktop } = useDeviceDetection(); @@ -58,11 +121,19 @@ export default function ProHome() { const isPushConnected = useEmailStore((s) => s.isPushConnected); const tabs = useProTabStore((s) => s.tabs); - const activeTabId = useProTabStore((s) => s.activeTabId); + const activeMainTabId = useProTabStore((s) => s.activeTabId); + const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId); + const splitOrientation = useProTabStore((s) => s.splitOrientation); + const focusedPaneId = useProTabStore((s) => s.focusedPaneId); const loadedTabIds = useProTabStore((s) => s.loadedTabIds); const openTab = useProTabStore((s) => s.openTab); const closeTab = useProTabStore((s) => s.closeTab); const setActiveTab = useProTabStore((s) => s.setActiveTab); + const setFocusedPane = useProTabStore((s) => s.setFocusedPane); + const moveTabToPane = useProTabStore((s) => s.moveTabToPane); + + const [isTabDragging, setIsTabDragging] = useState(false); + const [splitDropTarget, setSplitDropTarget] = useState(null); // Auth bootstrap (mirrors standard page) useEffect(() => { @@ -89,24 +160,87 @@ export default function ProHome() { } }, [initialCheckDone, isMobile, isTablet]); - const activeTab = useMemo( - () => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0], - [tabs, activeTabId] - ); + const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]); + const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]); + + const focusedActiveTab = useMemo(() => { + const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId; + return tabs.find((t) => t.id === id) ?? null; + }, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]); const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => { openTab(itemId); return true; }; - // Only highlight the rail when an "app" tab is active; compose/email tabs - // don't correspond to any rail item. const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null = - activeTab && ( - activeTab.kind === 'mail' || activeTab.kind === 'calendar' - || activeTab.kind === 'contacts' || activeTab.kind === 'files' - || activeTab.kind === 'settings' - ) ? activeTab.kind : null; + focusedActiveTab && ( + focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar' + || focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files' + || focusedActiveTab.kind === 'settings' + ) ? focusedActiveTab.kind : null; + + // ---- Split drop zones ---- + + const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME); + + const computeDropTarget = (e: DragEvent): DropTarget => { + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const xFrac = x / rect.width; + const yFrac = y / rect.height; + // Edges: outer 22% of the body becomes a split drop target. The cursor's + // dominant axis decides which side activates. + const fromLeft = xFrac; + const fromRight = 1 - xFrac; + const fromTop = yFrac; + const fromBottom = 1 - yFrac; + const min = Math.min(fromLeft, fromRight, fromTop, fromBottom); + if (min > 0.22) return null; + if (min === fromRight) return 'right'; + if (min === fromLeft) return 'left'; + if (min === fromBottom) return 'bottom'; + return 'top'; + }; + + const handleBodyDragOver = (e: DragEvent) => { + if (!isProTabDrag(e)) return; + if (splitOrientation !== null) return; // already split — body drops disabled + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const next = computeDropTarget(e); + if (next !== splitDropTarget) setSplitDropTarget(next); + }; + + const handleBodyDragLeave = (e: DragEvent) => { + const next = e.relatedTarget as Node | null; + if (next && e.currentTarget.contains(next)) return; + setSplitDropTarget(null); + }; + + const handleBodyDrop = (e: DragEvent) => { + if (!isProTabDrag(e)) return; + if (splitOrientation !== null) return; + const target = computeDropTarget(e); + setSplitDropTarget(null); + setIsTabDragging(false); + if (!target) return; + e.preventDefault(); + const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME); + if (!draggedId) return; + // The dragged tab must currently be in 'main' (the only pane right now). + // Moving it to 'split' creates the split. + const orientation = (target === 'left' || target === 'right') ? 'vertical' : 'horizontal'; + moveTabToPane(draggedId, 'split', orientation); + // 'left'/'top' targets put the split pane on the leading edge — flipped + // visually by swapping the rendered order below. We track it via the + // splitLeading flag derived from the last drop. + setSplitLeading(target === 'left' || target === 'top'); + }; + + // Whether the split pane renders before (true) or after (false) the main pane. + const [splitLeading, setSplitLeading] = useState(false); // Loading state (matches standard page exactly) if (!initialCheckDone || authLoading || !isAuthenticated || !client) { @@ -122,6 +256,53 @@ export default function ProHome() { if (!isDesktop) return null; + const isSplit = splitOrientation !== null && splitTabs.length > 0; + + const mainPane = ( + + ); + + const splitPane = isSplit ? ( + + ) : null; + + const splitDivider = isSplit ? ( +