"use client"; import { useCallback, useEffect, useSyncExternalStore } from "react"; import { useUIStore } from "@/stores/ui-store"; import { usePaneSize } from "@/hooks/use-pane-size"; // Tailwind v4 breakpoints const BREAKPOINTS = { sm: 640, md: 768, lg: 1024, xl: 1280, "2xl": 1536, } as const; const getMediaQueryServerSnapshot = () => false; /** * SSR-safe media query hook. On SSR and the first hydration pass we report * `false`; on all subsequent client renders (including client-side navigation * remounts) we read `matchMedia` synchronously, so components don't flash * through a one-frame "mobile" layout on desktop. */ export function useMediaQuery(query: string): boolean { const subscribe = useCallback( (callback: () => void) => { const mq = window.matchMedia(query); mq.addEventListener("change", callback); return () => mq.removeEventListener("change", callback); }, [query], ); const getSnapshot = useCallback( () => window.matchMedia(query).matches, [query], ); return useSyncExternalStore(subscribe, getSnapshot, getMediaQueryServerSnapshot); } /** * When the Pro shell renders a page inside a (possibly split) pane, that pane * publishes its measured width via `PaneSizeContext`. Inner pages should * branch their layout against the pane width - not the full viewport - so a * narrow pane gets the mobile/tablet layout instead of overflowing. * * Returns `null` when no pane size is published, signalling the caller to * fall back to `window.matchMedia`. */ function classifyPane(paneWidth: number | null) { if (paneWidth === null) return null; return { isMobile: paneWidth < BREAKPOINTS.md, isTablet: paneWidth >= BREAKPOINTS.md && paneWidth < BREAKPOINTS.lg, isDesktop: paneWidth >= BREAKPOINTS.lg, }; } /** * Hook to detect device type and sync with UI store * Uses Tailwind breakpoints: mobile < 768px, tablet 768-1024px, desktop > 1024px * * When invoked inside a Pro pane, the returned values reflect the pane's * width instead of the window's. The global UI store is NOT updated in that * case - two split panes would otherwise fight to write conflicting values, * and the store is meant to mirror the actual viewport for callers that read * it directly (mobile navigation helpers etc.). */ export function useDeviceDetection() { const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore(); const paneWidth = usePaneSize(); const paneClassification = classifyPane(paneWidth); const isMobileQuery = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`); const isTabletQuery = useMediaQuery( `(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)` ); const isDesktopQuery = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`); useEffect(() => { if (paneClassification) return; setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery); }, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType, paneClassification]); if (paneClassification) return paneClassification; return { isMobile, isTablet, isDesktop }; } /** * Convenience hooks for specific breakpoints */ export function useIsMobile() { const paneWidth = usePaneSize(); const viewport = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`); return paneWidth !== null ? paneWidth < BREAKPOINTS.md : viewport; } export function useIsTablet() { const paneWidth = usePaneSize(); const viewport = useMediaQuery( `(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)` ); return paneWidth !== null ? paneWidth >= BREAKPOINTS.md && paneWidth < BREAKPOINTS.lg : viewport; } export function useIsDesktop() { const paneWidth = usePaneSize(); const viewport = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`); return paneWidth !== null ? paneWidth >= BREAKPOINTS.lg : viewport; } export function useBreakpoint(breakpoint: keyof typeof BREAKPOINTS) { const paneWidth = usePaneSize(); const viewport = useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]}px)`); return paneWidth !== null ? paneWidth >= BREAKPOINTS[breakpoint] : viewport; }