mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
120 lines
4.3 KiB
TypeScript
120 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
|
import { useUIStore } from "@/stores/ui-store";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { usePaneSize } from "@/hooks/use-pane-size";
|
|
|
|
// Tailwind v4 breakpoints, in px at the default 16px text size.
|
|
const BREAKPOINTS = {
|
|
sm: 640,
|
|
md: 768,
|
|
lg: 1024,
|
|
xl: 1280,
|
|
"2xl": 1536,
|
|
} as const;
|
|
|
|
const getMediaQueryServerSnapshot = () => false;
|
|
|
|
/**
|
|
* The media query for "narrower than `px`" in rem, as Tailwind writes its own
|
|
* breakpoints (`max-md:` is `width < 48rem`). A rem in a media query is the
|
|
* browser's default text size, so someone who sets a larger default (say
|
|
* 32px) gets the layout the CSS draws at that size: a 1440px window then
|
|
* counts as a phone in both, instead of the scripts choosing the desktop
|
|
* panes while the CSS hides them.
|
|
*/
|
|
export function narrowerThan(px: number): string {
|
|
return `(width < ${px / 16}rem)`;
|
|
}
|
|
|
|
/** The media query for "at least `px` wide", in rem (see narrowerThan). */
|
|
export function atLeast(px: number): string {
|
|
return `(width >= ${px / 16}rem)`;
|
|
}
|
|
|
|
/**
|
|
* 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(useShallow((state) => ({ setDeviceType: state.setDeviceType, isMobile: state.isMobile, isTablet: state.isTablet, isDesktop: state.isDesktop })));
|
|
const paneWidth = usePaneSize();
|
|
const paneClassification = classifyPane(paneWidth);
|
|
|
|
const isMobileQuery = useMediaQuery(narrowerThan(BREAKPOINTS.md));
|
|
const isTabletQuery = useMediaQuery(`${atLeast(BREAKPOINTS.md)} and ${narrowerThan(BREAKPOINTS.lg)}`);
|
|
const isDesktopQuery = useMediaQuery(atLeast(BREAKPOINTS.lg));
|
|
|
|
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(narrowerThan(BREAKPOINTS.md));
|
|
return paneWidth !== null ? paneWidth < BREAKPOINTS.md : viewport;
|
|
}
|
|
|
|
export function useIsDesktop() {
|
|
const paneWidth = usePaneSize();
|
|
const viewport = useMediaQuery(atLeast(BREAKPOINTS.lg));
|
|
return paneWidth !== null ? paneWidth >= BREAKPOINTS.lg : viewport;
|
|
}
|