"use client"; import * as React from "react"; /** Tailwind `md` breakpoint — below it the app renders its mobile shell. */ export const MOBILE_QUERY = "(max-width: 767.98px)"; export const TABLET_QUERY = "(min-width: 768px) and (max-width: 1023.98px)"; export const COARSE_QUERY = "(pointer: coarse)"; export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; function subscribe(query: string) { return (cb: () => void) => { const mq = window.matchMedia(query); mq.addEventListener("change", cb); return () => mq.removeEventListener("change", cb); }; } /** SSR-safe media query. Returns `fallback` on the server and on the first client render. */ export function useMediaQuery(query: string, fallback = false): boolean { return React.useSyncExternalStore( subscribe(query), () => window.matchMedia(query).matches, () => fallback, ); } export function useIsMobile(): boolean { return useMediaQuery(MOBILE_QUERY); } export function useIsTablet(): boolean { return useMediaQuery(TABLET_QUERY); } export function useIsCoarsePointer(): boolean { return useMediaQuery(COARSE_QUERY); } export function usePrefersReducedMotion(): boolean { return useMediaQuery(REDUCED_MOTION_QUERY); } /** True after hydration — use to gate `resolvedTheme`, `window`, etc. */ export function useMounted(): boolean { return React.useSyncExternalStore( () => () => {}, () => true, () => false, ); } /** * Tracks the on-screen keyboard through `visualViewport` and publishes the hidden * height as the CSS variable `--kb` on . Layouts use `.h-app` * (`calc(100dvh - var(--kb))`) so the composer always stays above the keyboard. * Returns the current inset in px. */ export function useKeyboardInset(): number { const [inset, setInset] = React.useState(0); React.useEffect(() => { const vv = window.visualViewport; if (!vv) return; let raf = 0; const update = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { // Difference between the layout viewport and the visual viewport = keyboard (iOS/Android). const hidden = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)); // Ignore tiny deltas from browser chrome animations. const kb = hidden > 60 ? hidden : 0; document.documentElement.style.setProperty("--kb", `${kb}px`); document.documentElement.toggleAttribute("data-keyboard", kb > 0); setInset(kb); // iOS scrolls the page when an input focuses; snap it back so fixed elements line up. if (kb > 0 && window.scrollY !== 0) window.scrollTo(0, 0); }); }; vv.addEventListener("resize", update); vv.addEventListener("scroll", update); update(); return () => { vv.removeEventListener("resize", update); vv.removeEventListener("scroll", update); cancelAnimationFrame(raf); document.documentElement.style.setProperty("--kb", "0px"); document.documentElement.removeAttribute("data-keyboard"); }; }, []); return inset; } export interface LongPressOptions { /** ms before the press fires (default 420). */ delay?: number; /** Movement tolerance in px before the press is cancelled (default 10). */ tolerance?: number; onLongPress: (e: { x: number; y: number; target: EventTarget | null }) => void; /** Optional plain-click handler (fires only when no long press happened). */ onClick?: (e: React.PointerEvent) => void; disabled?: boolean; } /** * Long-press gesture for touch surfaces. Returns pointer handlers to spread on the element. * Also suppresses the native context menu so iOS doesn't show its own callout. */ export function useLongPress({ delay = 420, tolerance = 10, onLongPress, onClick, disabled }: LongPressOptions) { const timer = React.useRef | null>(null); const start = React.useRef<{ x: number; y: number } | null>(null); const fired = React.useRef(false); const clear = React.useCallback(() => { if (timer.current) clearTimeout(timer.current); timer.current = null; start.current = null; }, []); React.useEffect(() => clear, [clear]); if (disabled) return {}; return { onPointerDown: (e: React.PointerEvent) => { if (e.pointerType === "mouse" && e.button !== 0) return; fired.current = false; start.current = { x: e.clientX, y: e.clientY }; const target = e.target; timer.current = setTimeout(() => { fired.current = true; if (navigator.vibrate) navigator.vibrate(8); onLongPress({ x: start.current?.x ?? e.clientX, y: start.current?.y ?? e.clientY, target }); clear(); }, delay); }, onPointerMove: (e: React.PointerEvent) => { if (!start.current) return; if (Math.abs(e.clientX - start.current.x) > tolerance || Math.abs(e.clientY - start.current.y) > tolerance) clear(); }, onPointerUp: (e: React.PointerEvent) => { const wasPending = Boolean(timer.current); clear(); if (wasPending && !fired.current) onClick?.(e); }, onPointerCancel: clear, onPointerLeave: clear, onContextMenu: (e: React.MouseEvent) => { // Touch long-press triggers contextmenu on Android; we already handle it. if (fired.current || timer.current) e.preventDefault(); }, }; } export interface SwipeOptions { onSwipeLeft?: () => void; onSwipeRight?: () => void; onSwipeDown?: () => void; onSwipeUp?: () => void; /** px of travel needed (default 56). */ threshold?: number; /** Only start a horizontal swipe when the touch begins within `edge` px of the left edge. */ edge?: number; disabled?: boolean; /** Ignore swipes whose start target (or ancestor) matches this selector, e.g. horizontally scrollable areas. */ ignoreSelector?: string; } /** * Attaches touch swipe recognition to a DOM element (via ref). Direction is locked after the * first 12px so vertical scrolling is never hijacked. */ export function useSwipe(ref: React.RefObject, { onSwipeLeft, onSwipeRight, onSwipeDown, onSwipeUp, threshold = 56, edge, disabled, ignoreSelector }: SwipeOptions) { React.useEffect(() => { const el = ref.current; if (!el || disabled) return; let sx = 0, sy = 0, dx = 0, dy = 0, active = false, axis: "x" | "y" | null = null; const onStart = (e: TouchEvent) => { if (e.touches.length !== 1) return; const t = e.touches[0]; if (edge !== undefined && t.clientX > edge) return; if (ignoreSelector && (e.target as Element | null)?.closest?.(ignoreSelector)) return; sx = t.clientX; sy = t.clientY; dx = 0; dy = 0; active = true; axis = null; }; const onMove = (e: TouchEvent) => { if (!active) return; const t = e.touches[0]; dx = t.clientX - sx; dy = t.clientY - sy; if (!axis && (Math.abs(dx) > 12 || Math.abs(dy) > 12)) axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; }; const onEnd = () => { if (!active) return; active = false; if (axis === "x" && Math.abs(dx) >= threshold) (dx < 0 ? onSwipeLeft : onSwipeRight)?.(); else if (axis === "y" && Math.abs(dy) >= threshold) (dy < 0 ? onSwipeUp : onSwipeDown)?.(); }; el.addEventListener("touchstart", onStart, { passive: true }); el.addEventListener("touchmove", onMove, { passive: true }); el.addEventListener("touchend", onEnd); el.addEventListener("touchcancel", onEnd); return () => { el.removeEventListener("touchstart", onStart); el.removeEventListener("touchmove", onMove); el.removeEventListener("touchend", onEnd); el.removeEventListener("touchcancel", onEnd); }; }, [ref, onSwipeLeft, onSwipeRight, onSwipeDown, onSwipeUp, threshold, edge, disabled, ignoreSelector]); } /** Persisted boolean/string/JSON state in localStorage (SSR-safe: initial render uses `initial`). */ export function useLocalStorage(key: string, initial: T): [T, (v: T | ((prev: T) => T)) => void] { const [value, setValue] = React.useState(initial); const loaded = React.useRef(false); React.useEffect(() => { try { const raw = window.localStorage.getItem(key); // eslint-disable-next-line react-hooks/set-state-in-effect if (raw !== null) setValue(JSON.parse(raw) as T); } catch { /* ignore */ } loaded.current = true; }, [key]); const set = React.useCallback( (v: T | ((prev: T) => T)) => { setValue((prev) => { const next = typeof v === "function" ? (v as (p: T) => T)(prev) : v; try { window.localStorage.setItem(key, JSON.stringify(next)); } catch { /* quota */ } return next; }); }, [key], ); return [value, set]; } /** Debounced value. */ export function useDebounced(value: T, ms = 150): T { const [v, setV] = React.useState(value); React.useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]); return v; } /** Copy-to-clipboard with a transient `copied` flag. */ export function useCopy(resetMs = 1400): [boolean, (text: string) => Promise] { const [copied, setCopied] = React.useState(false); const copy = React.useCallback( async (text: string) => { try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), resetMs); } catch { /* ignore */ } }, [resetMs], ); return [copied, copy]; } /** Whether the document is displayed as an installed PWA. */ export function useStandalone(): boolean { return useMediaQuery("(display-mode: standalone)"); } /** Tracks the active index of a `.snap-row` carousel and lets you scroll to a panel. */ export function useSnapCarousel(count: number) { const ref = React.useRef(null); const [index, setIndex] = React.useState(0); React.useEffect(() => { const el = ref.current; if (!el) return; let raf = 0; const onScroll = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { const w = el.clientWidth || 1; setIndex(Math.max(0, Math.min(count - 1, Math.round(el.scrollLeft / w)))); }); }; el.addEventListener("scroll", onScroll, { passive: true }); return () => { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(raf); }; }, [count]); const scrollTo = React.useCallback((i: number, smooth = true) => { const el = ref.current; if (!el) return; el.scrollTo({ left: i * el.clientWidth, behavior: smooth ? "smooth" : "auto" }); }, []); return { ref, index, scrollTo }; }