TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";34/** Tailwind `md` breakpoint — below it the app renders its mobile shell. */5export const MOBILE_QUERY = "(max-width: 767.98px)";6export const TABLET_QUERY = "(min-width: 768px) and (max-width: 1023.98px)";7export const COARSE_QUERY = "(pointer: coarse)";8export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";910function subscribe(query: string) {11 return (cb: () => void) => {12 const mq = window.matchMedia(query);13 mq.addEventListener("change", cb);14 return () => mq.removeEventListener("change", cb);15 };16}1718/** SSR-safe media query. Returns `fallback` on the server and on the first client render. */19export function useMediaQuery(query: string, fallback = false): boolean {20 return React.useSyncExternalStore(21 subscribe(query),22 () => window.matchMedia(query).matches,23 () => fallback,24 );25}2627export function useIsMobile(): boolean {28 return useMediaQuery(MOBILE_QUERY);29}30export function useIsTablet(): boolean {31 return useMediaQuery(TABLET_QUERY);32}33export function useIsCoarsePointer(): boolean {34 return useMediaQuery(COARSE_QUERY);35}36export function usePrefersReducedMotion(): boolean {37 return useMediaQuery(REDUCED_MOTION_QUERY);38}3940/** True after hydration — use to gate `resolvedTheme`, `window`, etc. */41export function useMounted(): boolean {42 return React.useSyncExternalStore(43 () => () => {},44 () => true,45 () => false,46 );47}4849/**50 * Tracks the on-screen keyboard through `visualViewport` and publishes the hidden51 * height as the CSS variable `--kb` on <html>. Layouts use `.h-app`52 * (`calc(100dvh - var(--kb))`) so the composer always stays above the keyboard.53 * Returns the current inset in px.54 */55export function useKeyboardInset(): number {56 const [inset, setInset] = React.useState(0);57 React.useEffect(() => {58 const vv = window.visualViewport;59 if (!vv) return;60 let raf = 0;61 const update = () => {62 cancelAnimationFrame(raf);63 raf = requestAnimationFrame(() => {64 // Difference between the layout viewport and the visual viewport = keyboard (iOS/Android).65 const hidden = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));66 // Ignore tiny deltas from browser chrome animations.67 const kb = hidden > 60 ? hidden : 0;68 document.documentElement.style.setProperty("--kb", `${kb}px`);69 document.documentElement.toggleAttribute("data-keyboard", kb > 0);70 setInset(kb);71 // iOS scrolls the page when an input focuses; snap it back so fixed elements line up.72 if (kb > 0 && window.scrollY !== 0) window.scrollTo(0, 0);73 });74 };75 vv.addEventListener("resize", update);76 vv.addEventListener("scroll", update);77 update();78 return () => {79 vv.removeEventListener("resize", update);80 vv.removeEventListener("scroll", update);81 cancelAnimationFrame(raf);82 document.documentElement.style.setProperty("--kb", "0px");83 document.documentElement.removeAttribute("data-keyboard");84 };85 }, []);86 return inset;87}8889export interface LongPressOptions {90 /** ms before the press fires (default 420). */91 delay?: number;92 /** Movement tolerance in px before the press is cancelled (default 10). */93 tolerance?: number;94 onLongPress: (e: { x: number; y: number; target: EventTarget | null }) => void;95 /** Optional plain-click handler (fires only when no long press happened). */96 onClick?: (e: React.PointerEvent) => void;97 disabled?: boolean;98}99100/**101 * Long-press gesture for touch surfaces. Returns pointer handlers to spread on the element.102 * Also suppresses the native context menu so iOS doesn't show its own callout.103 */104export function useLongPress({ delay = 420, tolerance = 10, onLongPress, onClick, disabled }: LongPressOptions) {105 const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);106 const start = React.useRef<{ x: number; y: number } | null>(null);107 const fired = React.useRef(false);108109 const clear = React.useCallback(() => {110 if (timer.current) clearTimeout(timer.current);111 timer.current = null;112 start.current = null;113 }, []);114115 React.useEffect(() => clear, [clear]);116117 if (disabled) return {};118 return {119 onPointerDown: (e: React.PointerEvent) => {120 if (e.pointerType === "mouse" && e.button !== 0) return;121 fired.current = false;122 start.current = { x: e.clientX, y: e.clientY };123 const target = e.target;124 timer.current = setTimeout(() => {125 fired.current = true;126 if (navigator.vibrate) navigator.vibrate(8);127 onLongPress({ x: start.current?.x ?? e.clientX, y: start.current?.y ?? e.clientY, target });128 clear();129 }, delay);130 },131 onPointerMove: (e: React.PointerEvent) => {132 if (!start.current) return;133 if (Math.abs(e.clientX - start.current.x) > tolerance || Math.abs(e.clientY - start.current.y) > tolerance) clear();134 },135 onPointerUp: (e: React.PointerEvent) => {136 const wasPending = Boolean(timer.current);137 clear();138 if (wasPending && !fired.current) onClick?.(e);139 },140 onPointerCancel: clear,141 onPointerLeave: clear,142 onContextMenu: (e: React.MouseEvent) => {143 // Touch long-press triggers contextmenu on Android; we already handle it.144 if (fired.current || timer.current) e.preventDefault();145 },146 };147}148149export interface SwipeOptions {150 onSwipeLeft?: () => void;151 onSwipeRight?: () => void;152 onSwipeDown?: () => void;153 onSwipeUp?: () => void;154 /** px of travel needed (default 56). */155 threshold?: number;156 /** Only start a horizontal swipe when the touch begins within `edge` px of the left edge. */157 edge?: number;158 disabled?: boolean;159 /** Ignore swipes whose start target (or ancestor) matches this selector, e.g. horizontally scrollable areas. */160 ignoreSelector?: string;161}162163/**164 * Attaches touch swipe recognition to a DOM element (via ref). Direction is locked after the165 * first 12px so vertical scrolling is never hijacked.166 */167export function useSwipe<T extends HTMLElement>(ref: React.RefObject<T | null>, { onSwipeLeft, onSwipeRight, onSwipeDown, onSwipeUp, threshold = 56, edge, disabled, ignoreSelector }: SwipeOptions) {168 React.useEffect(() => {169 const el = ref.current;170 if (!el || disabled) return;171 let sx = 0, sy = 0, dx = 0, dy = 0, active = false, axis: "x" | "y" | null = null;172 const onStart = (e: TouchEvent) => {173 if (e.touches.length !== 1) return;174 const t = e.touches[0];175 if (edge !== undefined && t.clientX > edge) return;176 if (ignoreSelector && (e.target as Element | null)?.closest?.(ignoreSelector)) return;177 sx = t.clientX; sy = t.clientY; dx = 0; dy = 0; active = true; axis = null;178 };179 const onMove = (e: TouchEvent) => {180 if (!active) return;181 const t = e.touches[0];182 dx = t.clientX - sx; dy = t.clientY - sy;183 if (!axis && (Math.abs(dx) > 12 || Math.abs(dy) > 12)) axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";184 };185 const onEnd = () => {186 if (!active) return;187 active = false;188 if (axis === "x" && Math.abs(dx) >= threshold) (dx < 0 ? onSwipeLeft : onSwipeRight)?.();189 else if (axis === "y" && Math.abs(dy) >= threshold) (dy < 0 ? onSwipeUp : onSwipeDown)?.();190 };191 el.addEventListener("touchstart", onStart, { passive: true });192 el.addEventListener("touchmove", onMove, { passive: true });193 el.addEventListener("touchend", onEnd);194 el.addEventListener("touchcancel", onEnd);195 return () => {196 el.removeEventListener("touchstart", onStart);197 el.removeEventListener("touchmove", onMove);198 el.removeEventListener("touchend", onEnd);199 el.removeEventListener("touchcancel", onEnd);200 };201 }, [ref, onSwipeLeft, onSwipeRight, onSwipeDown, onSwipeUp, threshold, edge, disabled, ignoreSelector]);202}203204/** Persisted boolean/string/JSON state in localStorage (SSR-safe: initial render uses `initial`). */205export function useLocalStorage<T>(key: string, initial: T): [T, (v: T | ((prev: T) => T)) => void] {206 const [value, setValue] = React.useState<T>(initial);207 const loaded = React.useRef(false);208 React.useEffect(() => {209 try {210 const raw = window.localStorage.getItem(key);211 // eslint-disable-next-line react-hooks/set-state-in-effect212 if (raw !== null) setValue(JSON.parse(raw) as T);213 } catch {214 /* ignore */215 }216 loaded.current = true;217 }, [key]);218 const set = React.useCallback(219 (v: T | ((prev: T) => T)) => {220 setValue((prev) => {221 const next = typeof v === "function" ? (v as (p: T) => T)(prev) : v;222 try {223 window.localStorage.setItem(key, JSON.stringify(next));224 } catch {225 /* quota */226 }227 return next;228 });229 },230 [key],231 );232 return [value, set];233}234235/** Debounced value. */236export function useDebounced<T>(value: T, ms = 150): T {237 const [v, setV] = React.useState(value);238 React.useEffect(() => {239 const t = setTimeout(() => setV(value), ms);240 return () => clearTimeout(t);241 }, [value, ms]);242 return v;243}244245/** Copy-to-clipboard with a transient `copied` flag. */246export function useCopy(resetMs = 1400): [boolean, (text: string) => Promise<void>] {247 const [copied, setCopied] = React.useState(false);248 const copy = React.useCallback(249 async (text: string) => {250 try {251 await navigator.clipboard.writeText(text);252 setCopied(true);253 setTimeout(() => setCopied(false), resetMs);254 } catch {255 /* ignore */256 }257 },258 [resetMs],259 );260 return [copied, copy];261}262263/** Whether the document is displayed as an installed PWA. */264export function useStandalone(): boolean {265 return useMediaQuery("(display-mode: standalone)");266}267268/** Tracks the active index of a `.snap-row` carousel and lets you scroll to a panel. */269export function useSnapCarousel<T extends HTMLElement>(count: number) {270 const ref = React.useRef<T | null>(null);271 const [index, setIndex] = React.useState(0);272 React.useEffect(() => {273 const el = ref.current;274 if (!el) return;275 let raf = 0;276 const onScroll = () => {277 cancelAnimationFrame(raf);278 raf = requestAnimationFrame(() => {279 const w = el.clientWidth || 1;280 setIndex(Math.max(0, Math.min(count - 1, Math.round(el.scrollLeft / w))));281 });282 };283 el.addEventListener("scroll", onScroll, { passive: true });284 return () => {285 el.removeEventListener("scroll", onScroll);286 cancelAnimationFrame(raf);287 };288 }, [count]);289 const scrollTo = React.useCallback((i: number, smooth = true) => {290 const el = ref.current;291 if (!el) return;292 el.scrollTo({ left: i * el.clientWidth, behavior: smooth ? "smooth" : "auto" });293 }, []);294 return { ref, index, scrollTo };295}296