"use client"; import { useCallback, useEffect, useRef, useState, } from "react"; import { api } from "~/trpc/react"; import { AnimationPreferencesContext, DEFAULT_PREFERS_REDUCED, DEFAULT_SPEED, applyPreferencesToDOM, clampSpeed, readLocalStorage, writeLocalStorage, type AnimationPreferencesContextValue, type AnimationPreferencesProviderProps, } from "~/components/providers/animation-preferences-provider"; type PartialPrefs = { prefersReducedMotion?: boolean; animationSpeedMultiplier?: number; }; /** Dashboard animation preferences with tRPC sync. Must render inside TRPCReactProvider. */ export function AnimationPreferencesProviderSynced({ children, initial, autoSync = true, }: AnimationPreferencesProviderProps & { autoSync?: boolean }) { const updateMutation = api.settings.updateAnimationPreferences.useMutation(); const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery( undefined, { refetchOnWindowFocus: false, staleTime: 60_000, retry: false, }, ); const [prefersReducedMotion, setPrefersReducedMotion] = useState( initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED, ); const [animationSpeedMultiplier, setAnimationSpeedMultiplier] = useState( clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED), ); const [lastSyncedAt, setLastSyncedAt] = useState(null); const pendingSyncRef = useRef(null); const isHydratedRef = useRef(false); const serverHydratedRef = useRef(false); const [isUpdating, setIsUpdating] = useState(false); useEffect(() => { if (typeof window === "undefined") return; const stored = readLocalStorage(); const systemReduced = window.matchMedia?.( "(prefers-reduced-motion: reduce)", ).matches; const finalPrefers = stored?.prefersReducedMotion ?? initial?.prefersReducedMotion ?? systemReduced ?? DEFAULT_PREFERS_REDUCED; const finalSpeed = clampSpeed( stored?.animationSpeedMultiplier ?? initial?.animationSpeedMultiplier ?? DEFAULT_SPEED, ); // eslint-disable-next-line react-hooks/set-state-in-effect setPrefersReducedMotion(finalPrefers); setAnimationSpeedMultiplier(finalSpeed); applyPreferencesToDOM({ prefersReducedMotion: finalPrefers, animationSpeedMultiplier: finalSpeed, }); isHydratedRef.current = true; }, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]); const performUpdate = useCallback( (patch: PartialPrefs, opts?: { sync?: boolean }) => { setIsUpdating(true); setPrefersReducedMotion((prev) => patch.prefersReducedMotion ?? prev); setAnimationSpeedMultiplier((prev) => clampSpeed(patch.animationSpeedMultiplier ?? prev), ); const normalizedPatch: PartialPrefs = { ...patch }; if ( normalizedPatch.prefersReducedMotion === true && normalizedPatch.animationSpeedMultiplier === undefined && animationSpeedMultiplier !== 1 ) { normalizedPatch.animationSpeedMultiplier = 1; } const nextReduced = normalizedPatch.prefersReducedMotion ?? prefersReducedMotion; let nextSpeed = clampSpeed( normalizedPatch.animationSpeedMultiplier ?? animationSpeedMultiplier, ); if (nextReduced && nextSpeed !== 1) { nextSpeed = 1; normalizedPatch.animationSpeedMultiplier ??= 1; } const newPrefs = { prefersReducedMotion: nextReduced, animationSpeedMultiplier: nextSpeed, }; applyPreferencesToDOM(newPrefs); writeLocalStorage(newPrefs); const shouldSync = opts?.sync ?? autoSync; if (shouldSync && serverPrefs) { pendingSyncRef.current = { prefersReducedMotion: patch.prefersReducedMotion, animationSpeedMultiplier: patch.animationSpeedMultiplier, }; updateMutation.mutate( { ...(normalizedPatch.prefersReducedMotion !== undefined && { prefersReducedMotion: normalizedPatch.prefersReducedMotion, }), ...(normalizedPatch.animationSpeedMultiplier !== undefined && { animationSpeedMultiplier: clampSpeed( normalizedPatch.animationSpeedMultiplier, ), }), }, { onSuccess: () => { setLastSyncedAt(Date.now()); pendingSyncRef.current = null; setIsUpdating(false); }, onError: () => { setIsUpdating(false); }, }, ); } else { setIsUpdating(false); } }, [ prefersReducedMotion, animationSpeedMultiplier, autoSync, updateMutation, serverPrefs, ], ); useEffect(() => { if (!isHydratedRef.current) return; if (serverHydratedRef.current) return; if (!serverPrefs) return; const localIsDefault = prefersReducedMotion === DEFAULT_PREFERS_REDUCED && animationSpeedMultiplier === DEFAULT_SPEED; const differs = serverPrefs.prefersReducedMotion !== prefersReducedMotion || serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier; if (localIsDefault || differs) { // eslint-disable-next-line react-hooks/set-state-in-effect performUpdate( { prefersReducedMotion: serverPrefs.prefersReducedMotion, animationSpeedMultiplier: serverPrefs.animationSpeedMultiplier, }, { sync: false }, ); } serverHydratedRef.current = true; }, [ serverPrefs, performUpdate, prefersReducedMotion, animationSpeedMultiplier, ]); const updatePreferences = useCallback< AnimationPreferencesContextValue["updatePreferences"] >( (patch, opts) => { performUpdate(patch, opts); }, [performUpdate], ); const handleSetReduced = useCallback( (val: boolean) => { updatePreferences({ prefersReducedMotion: val }); }, [updatePreferences], ); const handleSetSpeed = useCallback( (val: number) => { updatePreferences({ animationSpeedMultiplier: clampSpeed(val) }); }, [updatePreferences], ); const value: AnimationPreferencesContextValue = { prefersReducedMotion, animationSpeedMultiplier, updatePreferences, setPrefersReducedMotion: handleSetReduced, setAnimationSpeedMultiplier: handleSetSpeed, isUpdating: isUpdating || updateMutation.isPending, lastSyncedAt, }; return ( {children} ); }