Repository Public
1'use client';2import { useEffect, useSyncExternalStore } from 'react';3import { create } from 'zustand';45/** null until mounted (static export renders without a viewport). */6export type Breakpoint = 'mobile' | 'tablet' | 'desktop';7function subscribe(cb: () => void): () => void {8 const a = window.matchMedia('(min-width: 768px)');9 const b = window.matchMedia('(min-width: 1024px)');10 a.addEventListener('change', cb);11 b.addEventListener('change', cb);12 return () => {13 a.removeEventListener('change', cb);14 b.removeEventListener('change', cb);15 };16}17const snapshot = (): Breakpoint => (window.matchMedia('(min-width: 1024px)').matches ? 'desktop' : window.matchMedia('(min-width: 768px)').matches ? 'tablet' : 'mobile');18export function useBreakpoint(): Breakpoint | null {19 return useSyncExternalStore(subscribe, snapshot, () => null);20}2122interface VvState {23 keyboardOpen: boolean;24 height: number;25}26export const useVv = create<VvState>(() => ({ keyboardOpen: false, height: 0 }));2728/**29 * Drives the layout height from window.visualViewport so that, on iOS, the bottom of the app30 * (the key toolbar) sits exactly on top of the software keyboard. Sets --vvh / --vvt on <html>.31 */32export function useVisualViewport(): void {33 useEffect(() => {34 const vv = window.visualViewport;35 const root = document.documentElement;36 let maxH = 0;37 let lastW = 0;38 let raf = 0;39 const apply = () => {40 raf = 0;41 const h = Math.round(vv?.height ?? window.innerHeight);42 const w = Math.round(vv?.width ?? window.innerWidth);43 if (w !== lastW) {44 lastW = w; // rotation: forget the previous orientation's full height45 maxH = 0;46 }47 maxH = Math.max(maxH, h, vv ? 0 : window.innerHeight);48 root.style.setProperty('--vvh', `${h}px`);49 root.style.setProperty('--vvt', `${Math.round(vv?.offsetTop ?? 0)}px`);50 const keyboardOpen = maxH - h > 140;51 const s = useVv.getState();52 if (s.keyboardOpen !== keyboardOpen || s.height !== h) useVv.setState({ keyboardOpen, height: h });53 // iOS scrolls the layout viewport to reveal the focused field; the shell is fixed, so undo it.54 if (keyboardOpen && (window.scrollY !== 0 || window.scrollX !== 0)) window.scrollTo(0, 0);55 };56 const queue = () => {57 if (!raf) raf = requestAnimationFrame(apply);58 };59 apply();60 vv?.addEventListener('resize', queue);61 vv?.addEventListener('scroll', queue);62 window.addEventListener('resize', queue);63 window.addEventListener('orientationchange', queue);64 return () => {65 vv?.removeEventListener('resize', queue);66 vv?.removeEventListener('scroll', queue);67 window.removeEventListener('resize', queue);68 window.removeEventListener('orientationchange', queue);69 if (raf) cancelAnimationFrame(raf);70 };71 }, []);72}7374/** Re-render every `ms` (relative times, live durations). Paused while the tab is hidden. */75export function useNow(ms = 10_000): number {76 const store = getTicker(ms);77 return useSyncExternalStore(store.subscribe, store.get, store.get);78}79const tickers = new Map<number, { subscribe: (cb: () => void) => () => void; get: () => number }>();80function getTicker(ms: number) {81 let t = tickers.get(ms);82 if (t) return t;83 let now = Date.now();84 const subs = new Set<() => void>();85 let timer: ReturnType<typeof setInterval> | null = null;86 const tick = () => {87 if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;88 now = Date.now();89 subs.forEach((s) => s());90 };91 t = {92 subscribe(cb) {93 subs.add(cb);94 timer ??= setInterval(tick, ms);95 return () => {96 subs.delete(cb);97 if (subs.size === 0 && timer) {98 clearInterval(timer);99 timer = null;100 }101 };102 },103 get: () => now,104 };105 tickers.set(ms, t);106 return t;107}108