diff --git a/.gitignore b/.gitignore index 68e04ac..8b91f79 100755 --- a/.gitignore +++ b/.gitignore @@ -48,22 +48,10 @@ typings/ # Optional REPL history .node_repl_history -# Output of 'npm pack' -*.tgz - # dotenv environment variables file .env -# gatsby files -.cache/ -public # Mac files .DS_Store -# Yarn -yarn-error.log -.pnp/ -.pnp.js -# Yarn Integrity file -.yarn-integrity \ No newline at end of file diff --git a/.nvmrc b/.nvmrc deleted file mode 100755 index be046a9..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -10.12.0 \ No newline at end of file diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 index 826c5f3..99e3844 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Gabe Ragland +Copyright (c) 2023 ui.dev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 67389e8..c7a83f4 --- a/README.md +++ b/README.md @@ -1,7 +1,75 @@ - +![useHooks](https://usehooks.com/meta/og.jpg) -This is the repository for [usehooks.com](https://usehooks.com), a [Gatsby](https://www.gatsbyjs.org) powered blog that publishes easy to understand React Hook code recipes. +# useHooks -If you'd like to submit new post ideas, improve existing posts, or change anything about the website feel free to submit an issue or pull-request. +A collection of modern, server-safe React hooks – from the [ui.dev](https://ui.dev) team. -To run locally, `yarn`, then `yarn dev`, then open [localhost:8000](https://localhost:8000). +Compatible with React v18.0.0+. + +## Standard + +### Install + +`npm i @uidotdev/usehooks` + +### Hooks + +- [useBattery](https://usehooks.com/usebattery) +- [useClickAway](https://usehooks.com/useclickaway) +- [useCopyToClipboard](https://usehooks.com/usecopytoclipboard) +- [useCounter](https://usehooks.com/usecounter) +- [useDebounce](https://usehooks.com/usedebounce) +- [useDefault](https://usehooks.com/usedefault) +- [useDocumentTitle](https://usehooks.com/usedocumenttitle) +- [useFavicon](https://usehooks.com/usefavicon) +- [useGeolocation](https://usehooks.com/usegeolocation) +- [useHistoryState](https://usehooks.com/usehistorystate) +- [useHover](https://usehooks.com/usehover) +- [useIdle](https://usehooks.com/useidle) +- [useIntersectionObserver](https://usehooks.com/useintersectionobserver) +- [useIsClient](https://usehooks.com/useisclient) +- [useIsFirstRender](https://usehooks.com/useisfirstrender) +- [useList](https://usehooks.com/uselist) +- [useLocalStorage](https://usehooks.com/uselocalstorage) +- [useLockBodyScroll](https://usehooks.com/uselockbodyscroll) +- [useLongPress](https://usehooks.com/uselongpress) +- [useMap](https://usehooks.com/usemap) +- [useMeasure](https://usehooks.com/usemeasure) +- [useMediaQuery](https://usehooks.com/usemediaquery) +- [useMouse](https://usehooks.com/usemouse) +- [useNetworkState](https://usehooks.com/usenetworkstate) +- [useObjectState](https://usehooks.com/useobjectstate) +- [useOrientation](https://usehooks.com/useorientation) +- [usePreferredLanguage](https://usehooks.com/usepreferredlanguage) +- [usePrevious](https://usehooks.com/useprevious) +- [useQueue](https://usehooks.com/usequeue) +- [useRenderCount](https://usehooks.com/userendercount) +- [useRenderInfo](https://usehooks.com/userenderinfo) +- [useScript](https://usehooks.com/usescript) +- [useSessionStorage](https://usehooks.com/usesessionstorage) +- [useSet](https://usehooks.com/useset) +- [useThrottle](https://usehooks.com/usethrottle) +- [useToggle](https://usehooks.com/usetoggle) +- [useVisibilityChange](https://usehooks.com/usevisibilitychange) +- [useWindowScroll](https://usehooks.com/usewindowscroll) +- [useWindowSize](https://usehooks.com/usewindowsize) + +## Experimental + +### Install + +`npm i @uidotdev/usehooks@experimental react@experimental react-dom@experimental` + +### Hooks + +- [useContinuousRetry](https://usehooks.com/usecontinuousretry) +- [useCountdown](https://usehooks.com/usecountdown) +- [useEventListener](https://usehooks.com/useeventlistener) +- [useFetch](https://usehooks.com/usefetch) +- [useInterval](https://usehooks.com/useinterval) +- [useIntervalWhen](https://usehooks.com/useintervalwhen) +- [useKeyPress](https://usehooks.com/usekeypress) +- [useLogger](https://usehooks.com/uselogger) +- [usePageLeave](https://usehooks.com/usepageleave) +- [useRandomInterval](https://usehooks.com/userandominterval) +- [useTimeout](https://usehooks.com/usetimeout) diff --git a/gatsby-browser.js b/gatsby-browser.js deleted file mode 100755 index fded165..0000000 --- a/gatsby-browser.js +++ /dev/null @@ -1 +0,0 @@ -exports.onClientEntry = () => {}; diff --git a/gatsby-config.js b/gatsby-config.js deleted file mode 100755 index b09b966..0000000 --- a/gatsby-config.js +++ /dev/null @@ -1,95 +0,0 @@ -module.exports = { - siteMetadata: { - title: "useHooks", - description: "Easy to understand React Hook recipes", - siteUrl: "https://usehooks.com" - }, - plugins: [ - "gatsby-plugin-styled-components", - "gatsby-plugin-react-helmet", - { - resolve: "gatsby-source-filesystem", - options: { - path: `${__dirname}/src/pages`, - name: "pages" - } - }, - { - resolve: "gatsby-source-filesystem", - options: { - path: `${__dirname}/static/img`, - name: "images" - } - }, - { - resolve: `gatsby-transformer-remark`, - options: { - plugins: [ - { - resolve: "gatsby-remark-external-links", - options: { - target: "_blank", - rel: null - } - } - ] - } - }, - { - resolve: `gatsby-plugin-feed`, - options: { - query: ` - { - site { - siteMetadata { - title - description - siteUrl - site_url: siteUrl - } - } - } - `, - feeds: [ - { - serialize: ({ query: { site, allMarkdownRemark } }) => { - return allMarkdownRemark.edges.map(edge => { - return Object.assign({}, edge.node.frontmatter, { - description: edge.node.excerpt, - date: edge.node.frontmatter.date, - url: site.siteMetadata.siteUrl + edge.node.fields.slug, - guid: site.siteMetadata.siteUrl + edge.node.fields.slug, - custom_elements: [{ "content:encoded": edge.node.html }] - }); - }); - }, - query: ` - { - allMarkdownRemark( - limit: 1000, - sort: { order: DESC, fields: [frontmatter___date] }, - filter: { frontmatter: { templateKey: { eq: "post" } } } - ) { - edges { - node { - excerpt(pruneLength: 400) - html - fields { slug } - frontmatter { - title - date(formatString: "MMMM DD, YYYY") - } - } - } - } - } - `, - output: "/rss.xml", - title: "useHooks", - link: "https://usehooks.com" - } - ] - } - } - ] -}; diff --git a/gatsby-node.js b/gatsby-node.js deleted file mode 100755 index 59fcbe3..0000000 --- a/gatsby-node.js +++ /dev/null @@ -1,58 +0,0 @@ -const path = require("path"); -const { createFilePath } = require("gatsby-source-filesystem"); - -exports.createPages = ({ actions, graphql }) => { - const { createPage } = actions; - - return graphql(` - { - allMarkdownRemark(limit: 1000) { - edges { - node { - id - fields { - slug - } - frontmatter { - templateKey - } - } - } - } - } - `).then(result => { - if (result.errors) { - result.errors.forEach(e => console.error(e.toString())); - return Promise.reject(result.errors); - } - - const posts = result.data.allMarkdownRemark.edges; - - posts.forEach(edge => { - const id = edge.node.id; - createPage({ - path: edge.node.fields.slug, - component: path.resolve( - `src/templates/${String(edge.node.frontmatter.templateKey)}.js` - ), - // additional data can be passed via context - context: { - id - } - }); - }); - }); -}; - -exports.onCreateNode = ({ node, actions, getNode }) => { - const { createNodeField } = actions; - - if (node.internal.type === `MarkdownRemark`) { - const value = createFilePath({ node, getNode }); - createNodeField({ - name: `slug`, - node, - value - }); - } -}; diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..db01dc2 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,263 @@ +import * as React from "react"; + +export type BatteryManager = { + supported: boolean; + loading: boolean; + level: number | null; + charging: boolean | null; + chargingTime: number | null; + dischargingTime: number | null; +}; + +export type GeolocationState = { + loading: boolean; + accuracy: number | null; + altitude: number | null; + altitudeAccuracy: number | null; + heading: number | null; + latitude: number | null; + longitude: number | null; + speed: number | null; + timestamp: number | null; + error: GeolocationPositionError | null; +}; + +export type HistoryState = { + state: T; + set: (newPresent: T) => void; + undo: () => void; + redo: () => void; + clear: () => void; + canUndo: boolean; + canRedo: boolean; +}; + +export type LongPressOptions = { + threshold?: number; + onStart?: (e: Event) => void; + onFinish?: (e: Event) => void; + onCancel?: (e: Event) => void; +}; + +export type LongPressFns = { + onMouseDown: (e: React.MouseEvent) => void; + onMouseUp: (e: React.MouseEvent) => void; + onMouseLeave: (e: React.MouseEvent) => void; + onTouchStart: (e: React.TouchEvent) => void; + onTouchEnd: (e: React.TouchEvent) => void; +}; + +export type MousePosition = { + x: number; + y: number; + elementX: number; + elementY: number; + elementPositionX: number; + elementPositionY: number; +}; + +export type NetworkState = { + online: boolean; + downlink: number | null; + downlinkMax: number | null; + effectiveType: string | null; + rtt: number | null; + saveData: boolean | null; + type: string | null; +}; + +export type CustomList = { + set: (l: T[]) => void; + push: (element: T) => void; + removeAt: (index: number) => void; + insertAt: (index: number, element: T) => void; + updateAt: (index: number, element: T) => void; + clear: () => void; +}; + +export type CustomQueue = { + add: (element: T) => void; + remove: () => T | undefined; + clear: () => void; + first: T | undefined; + last: T | undefined; + size: number; + queue: T[]; +}; + +export type RenderInfo = { + name: string; + renders: number; + sinceLastRender: number; + timestamp: number; +}; + +export type SpeechOptions = { + lang?: string; + voice?: { + lang?: string; + name?: string; + }; + rate?: number; + pitch?: number; + volume?: number; +}; + +export type SpeechState = { + isPlaying: boolean; + status: "init" | "play" | "pause" | "stop"; + lang: string; + voiceInfo: { + lang: string; + name: string; + }; + rate: number; + pitch: number; + volume: number; +}; + +declare module "@uidotdev/usehooks" { + export function useBattery(): BatteryManager; + + export function useClickAway( + cb: (e: Event) => void + ): React.MutableRefObject; + + export function useCopyToClipboard(): [ + string | null, + (value: string) => Promise + ]; + + export function useCounter( + startingValue?: number, + options?: { + min?: number; + max?: number; + } + ): [ + number, + { + increment: () => void; + decrement: () => void; + set: (nextCount: number) => void; + reset: () => void; + } + ]; + + export function useDebounce(value: T, delay: number): T; + + export function useDefault( + initialValue: T, + defaultValue: T + ): [T, React.Dispatch>]; + + export function useDocumentTitle(title: string): void; + + export function useFavicon(url: string): void; + + export function useGeolocation(options?: PositionOptions): GeolocationState; + + export function useHistoryState(initialPresent?: T): HistoryState; + + export function useHover(): [ + React.RefCallback, + boolean + ]; + + export function useIdle(ms?: number): boolean; + + export function useIntersectionObserver( + options?: IntersectionObserverInit + ): [React.RefCallback, IntersectionObserverEntry | null]; + + export function useIsClient(): boolean; + + export function useIsFirstRender(): boolean; + + export function useList(defaultList?: T[]): [T[], CustomList]; + + export function useLocalStorage( + key: string, + initialValue?: T + ): [T, React.Dispatch>]; + + export function useLockBodyScroll(): void; + + export function useLongPress( + callback: (e: Event) => void, + options?: LongPressOptions + ): LongPressFns; + + export function useMap(initialState?: T): Map; + + export function useMeasure(): [ + React.RefCallback, + { + width: number | null; + height: number | null; + } + ]; + + export function useMediaQuery(query: string): boolean; + + export function useMouse(): [ + MousePosition, + React.MutableRefObject + ]; + + export function useNetworkState(): NetworkState; + + export function useObjectState(initialValue: T): [T, (arg: T) => void]; + + export function useOrientation(): { + angle: number; + type: string; + }; + + export function usePreferredLanguage(): string; + + export function usePrevious(newValue: T): T; + + export function useQueue(initialValue?: T[]): CustomQueue; + + export function useRenderCount(): number; + + export function useRenderInfo(name?: string): RenderInfo | undefined; + + export function useScript( + src: string, + options?: { + removeOnUnmount?: boolean; + } + ): "unknown" | "loading" | "ready" | "error"; + + export function useSessionStorage( + key: string, + initialValue: T + ): [T, React.Dispatch>]; + + export function useSet(values?: T[]): Set; + + export function useSpeech(text: string, options?: SpeechOptions): SpeechState; + + export function useThrottle(value: T, delay: number): T; + + export function useToggle( + initialValue?: boolean + ): [boolean, (newValue?: boolean) => void]; + + export function useVisibilityChange(): boolean; + + export function useWindowScroll(): [ + { + x: number | null; + y: number | null; + }, + (args: unknown) => void + ]; + + export function useWindowSize(): { + width: number | null; + height: number | null; + }; +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..f6e4fe2 --- /dev/null +++ b/index.js @@ -0,0 +1,1367 @@ +import * as React from "react"; + +function isShallowEqual(object1, object2) { + const keys1 = Object.keys(object1); + const keys2 = Object.keys(object2); + + if (keys1.length !== keys2.length) { + return false; + } + + for (let key of keys1) { + if (object1[key] !== object2[key]) { + return false; + } + } + + return true; +} + +function isTouchEvent({ nativeEvent }) { + return window.TouchEvent + ? nativeEvent instanceof TouchEvent + : "touches" in nativeEvent; +} + +function isMouseEvent(event) { + return event.nativeEvent instanceof MouseEvent; +} + +function throttle(cb, ms) { + let lastTime = 0; + return () => { + const now = Date.now(); + if (now - lastTime >= ms) { + cb(); + lastTime = now; + } + }; +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === "[object Object]"; +} + +function dispatchStorageEvent(key, newValue) { + window.dispatchEvent(new StorageEvent("storage", { key, newValue })); +} + +export function useBattery() { + const [state, setState] = React.useState({ + supported: true, + loading: true, + level: null, + charging: null, + chargingTime: null, + dischargingTime: null, + }); + + React.useEffect(() => { + if (!navigator.getBattery) { + setState((s) => ({ + ...s, + supported: false, + loading: false, + })); + return; + } + + let battery = null; + + const handleChange = () => { + setState({ + supported: true, + loading: false, + level: battery.level, + charging: battery.charging, + chargingTime: battery.chargingTime, + dischargingTime: battery.dischargingTime, + }); + }; + + navigator.getBattery().then((b) => { + battery = b; + handleChange(); + + b.addEventListener("levelchange", handleChange); + b.addEventListener("chargingchange", handleChange); + b.addEventListener("chargingtimechange", handleChange); + b.addEventListener("dischargingtimechange", handleChange); + }); + + return () => { + if (battery) { + battery.removeEventListener("levelchange", handleChange); + battery.removeEventListener("chargingchange", handleChange); + battery.removeEventListener("chargingtimechange", handleChange); + battery.removeEventListener("dischargingtimechange", handleChange); + } + }; + }, []); + + return state; +} + +export function useClickAway(cb) { + const ref = React.useRef(null); + const refCb = React.useRef(cb); + + React.useLayoutEffect(() => { + refCb.current = cb; + }); + + React.useEffect(() => { + const handler = (e) => { + const element = ref.current; + if (element && !element.contains(e.target)) { + refCb.current(e); + } + }; + + document.addEventListener("mousedown", handler); + document.addEventListener("touchstart", handler); + + return () => { + document.removeEventListener("mousedown", handler); + document.removeEventListener("touchstart", handler); + }; + }, []); + + return ref; +} + +function oldSchoolCopy(text) { + const tempTextArea = document.createElement("textarea"); + tempTextArea.value = text; + document.body.appendChild(tempTextArea); + tempTextArea.select(); + document.execCommand("copy"); + document.body.removeChild(tempTextArea); +} + +export function useCopyToClipboard() { + const [state, setState] = React.useState(null); + + const copyToClipboard = React.useCallback((value) => { + const handleCopy = async () => { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + setState(value); + } else { + throw new Error("writeText not supported"); + } + } catch (e) { + oldSchoolCopy(value); + setState(value); + } + }; + + handleCopy(); + }, []); + + return [state, copyToClipboard]; +} + +export function useCounter(startingValue = 0, options = {}) { + const { min, max } = options; + + if (typeof min === "number" && startingValue < min) { + throw new Error( + `Your starting value of ${startingValue} is less than your min of ${min}.` + ); + } + + if (typeof max === "number" && startingValue > max) { + throw new Error( + `Your starting value of ${startingValue} is greater than your max of ${max}.` + ); + } + + const [count, setCount] = React.useState(startingValue); + + const increment = React.useCallback(() => { + setCount((c) => { + const nextCount = c + 1; + + if (typeof max === "number" && nextCount > max) { + return c; + } + + return nextCount; + }); + }, [max]); + + const decrement = React.useCallback(() => { + setCount((c) => { + const nextCount = c - 1; + + if (typeof min === "number" && nextCount < min) { + return c; + } + + return nextCount; + }); + }, [min]); + + const set = React.useCallback( + (nextCount) => { + setCount((c) => { + if (typeof max === "number" && nextCount > max) { + return c; + } + + if (typeof min === "number" && nextCount < min) { + return c; + } + + return nextCount; + }); + }, + [max, min] + ); + + const reset = React.useCallback(() => { + setCount(startingValue); + }, [startingValue]); + + return [ + count, + { + increment, + decrement, + set, + reset, + }, + ]; +} + +export function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + React.useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +export function useDefault(initialValue, defaultValue) { + const [state, setState] = React.useState(initialValue); + + if (typeof state === "undefined" || state === null) { + return [defaultValue, setState]; + } + + return [state, setState]; +} + +export function useDocumentTitle(title) { + React.useEffect(() => { + document.title = title; + }, [title]); +} + +export function useFavicon(url) { + React.useEffect(() => { + let link = document.querySelector(`link[rel~="icon"]`); + + if (!link) { + link = document.createElement("link"); + link.type = "image/x-icon"; + link.rel = "icon"; + link.href = url; + document.head.appendChild(link); + } else { + link.href = url; + } + }, [url]); +} + +export function useGeolocation(options = {}) { + const [state, setState] = React.useState({ + loading: true, + accuracy: null, + altitude: null, + altitudeAccuracy: null, + heading: null, + latitude: null, + longitude: null, + speed: null, + timestamp: null, + error: null, + }); + + const optionsRef = React.useRef(options); + + React.useEffect(() => { + const onEvent = ({ coords, timestamp }) => { + setState({ + loading: false, + timestamp, + latitude: coords.latitude, + longitude: coords.longitude, + altitude: coords.altitude, + accuracy: coords.accuracy, + altitudeAccuracy: coords.altitudeAccuracy, + heading: coords.heading, + speed: coords.speed, + }); + }; + + const onEventError = (error) => { + setState((s) => ({ + ...s, + loading: false, + error, + })); + }; + + navigator.geolocation.getCurrentPosition( + onEvent, + onEventError, + optionsRef.current + ); + + const watchId = navigator.geolocation.watchPosition( + onEvent, + onEventError, + optionsRef.current + ); + + return () => { + navigator.geolocation.clearWatch(watchId); + }; + }, []); + + return state; +} + +const initialUseHistoryStateState = { + past: [], + present: null, + future: [], +}; + +const useHistoryStateReducer = (state, action) => { + const { past, present, future } = state; + + if (action.type === "UNDO") { + return { + past: past.slice(0, past.length - 1), + present: past[past.length - 1], + future: [present, ...future], + }; + } else if (action.type === "REDO") { + return { + past: [...past, present], + present: future[0], + future: future.slice(1), + }; + } else if (action.type === "SET") { + const { newPresent } = action; + + if (action.newPresent === present) { + return state; + } + + return { + past: [...past, present], + present: newPresent, + future: [], + }; + } else if (action.type === "CLEAR") { + return { + ...initialUseHistoryStateState, + present: action.initialPresent, + }; + } else { + throw new Error("Unsupported action type"); + } +}; + +export function useHistoryState(initialPresent = {}) { + const initialPresentRef = React.useRef(initialPresent); + + const [state, dispatch] = React.useReducer(useHistoryStateReducer, { + ...initialUseHistoryStateState, + present: initialPresentRef.current, + }); + + const canUndo = state.past.length !== 0; + const canRedo = state.future.length !== 0; + + const undo = React.useCallback(() => { + if (canUndo) { + dispatch({ type: "UNDO" }); + } + }, [canUndo]); + + const redo = React.useCallback(() => { + if (canRedo) { + dispatch({ type: "REDO" }); + } + }, [canRedo]); + + const set = React.useCallback( + (newPresent) => dispatch({ type: "SET", newPresent }), + [] + ); + + const clear = React.useCallback( + () => + dispatch({ type: "CLEAR", initialPresent: initialPresentRef.current }), + [] + ); + + return { state: state.present, set, undo, redo, clear, canUndo, canRedo }; +} + +export function useHover() { + const [hovering, setHovering] = React.useState(false); + const previousNode = React.useRef(null); + + const handleMouseEnter = React.useCallback(() => { + setHovering(true); + }, []); + + const handleMouseLeave = React.useCallback(() => { + setHovering(false); + }, []); + + const customRef = React.useCallback( + (node) => { + if (previousNode.current?.nodeType === Node.ELEMENT_NODE) { + previousNode.current.removeEventListener( + "mouseenter", + handleMouseEnter + ); + previousNode.current.removeEventListener( + "mouseleave", + handleMouseLeave + ); + } + + if (node?.nodeType === Node.ELEMENT_NODE) { + node.addEventListener("mouseenter", handleMouseEnter); + node.addEventListener("mouseleave", handleMouseLeave); + } + + previousNode.current = node; + }, + [handleMouseEnter, handleMouseLeave] + ); + + return [customRef, hovering]; +} + +export function useIdle(ms = 1000 * 60) { + const [idle, setIdle] = React.useState(false); + + React.useEffect(() => { + let timeoutId; + + const handleTimeout = () => { + setIdle(true); + }; + + const handleEvent = throttle((e) => { + setIdle(false); + + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(handleTimeout, ms); + }, 500); + + const handleVisibilityChange = () => { + if (!document.hidden) { + handleEvent(); + } + }; + + timeoutId = window.setTimeout(handleTimeout, ms); + + window.addEventListener("mousemove", handleEvent); + window.addEventListener("mousedown", handleEvent); + window.addEventListener("resize", handleEvent); + window.addEventListener("keydown", handleEvent); + window.addEventListener("touchstart", handleEvent); + window.addEventListener("wheel", handleEvent); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + window.removeEventListener("mousemove", handleEvent); + window.removeEventListener("mousedown", handleEvent); + window.removeEventListener("resize", handleEvent); + window.removeEventListener("keydown", handleEvent); + window.removeEventListener("touchstart", handleEvent); + window.removeEventListener("wheel", handleEvent); + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.clearTimeout(timeoutId); + }; + }, [ms]); + + return idle; +} + +export function useIntersectionObserver(options = {}) { + const { threshold = 1, root = null, rootMargin = "0px" } = options; + const [entry, setEntry] = React.useState(null); + + const previousObserver = React.useRef(null); + + const customRef = React.useCallback( + (node) => { + if (previousObserver.current) { + previousObserver.current.disconnect(); + previousObserver.current = null; + } + + if (node?.nodeType === Node.ELEMENT_NODE) { + const observer = new IntersectionObserver( + ([entry]) => { + setEntry(entry); + }, + { threshold, root, rootMargin } + ); + + observer.observe(node); + previousObserver.current = observer; + } + }, + [threshold, root, rootMargin] + ); + + return [customRef, entry]; +} + +export function useIsClient() { + const [isClient, setIsClient] = React.useState(false); + + React.useEffect(() => { + setIsClient(true); + }, []); + + return isClient; +} + +export function useIsFirstRender() { + const renderRef = React.useRef(true); + + if (renderRef.current === true) { + renderRef.current = false; + return true; + } + + return renderRef.current; +} + +export function useList(defaultList = []) { + const [list, setList] = React.useState(defaultList); + + const set = React.useCallback((l) => { + setList(l); + }, []); + + const push = React.useCallback((element) => { + setList((l) => [...l, element]); + }, []); + + const removeAt = React.useCallback((index) => { + setList((l) => [...l.slice(0, index), ...l.slice(index + 1)]); + }, []); + + const insertAt = React.useCallback((index, element) => { + setList((l) => [...l.slice(0, index), element, ...l.slice(index)]); + }, []); + + const updateAt = React.useCallback((index, element) => { + setList((l) => l.map((e, i) => (i === index ? element : e))); + }, []); + + const clear = React.useCallback(() => setList([]), []); + + return [list, { set, push, removeAt, insertAt, updateAt, clear }]; +} + +const setLocalStorageItem = (key, value) => { + const stringifiedValue = JSON.stringify(value); + window.localStorage.setItem(key, stringifiedValue); + dispatchStorageEvent(key, stringifiedValue); +}; + +const removeLocalStorageItem = (key) => { + window.localStorage.removeItem(key); + dispatchStorageEvent(key, null); +}; + +const getLocalStorageItem = (key) => { + return window.localStorage.getItem(key); +}; + +const useLocalStorageSubscribe = (callback) => { + window.addEventListener("storage", callback); + return () => window.removeEventListener("storage", callback); +}; + +const getLocalStorageServerSnapshot = () => { + throw Error("useLocalStorage is a client-only hook"); +}; + +export function useLocalStorage(key, initialValue) { + const getSnapshot = () => getLocalStorageItem(key); + + const store = React.useSyncExternalStore( + useLocalStorageSubscribe, + getSnapshot, + getLocalStorageServerSnapshot + ); + + const setState = React.useCallback( + (v) => { + try { + const nextState = typeof v === "function" ? v(JSON.parse(store)) : v; + + if (nextState === undefined || nextState === null) { + removeLocalStorageItem(key); + } else { + setLocalStorageItem(key, nextState); + } + } catch (e) { + console.warn(e); + } + }, + [key, store] + ); + + React.useEffect(() => { + if ( + getLocalStorageItem(key) === null && + typeof initialValue !== "undefined" + ) { + setLocalStorageItem(key, initialValue); + } + }, [key, initialValue]); + + return [store ? JSON.parse(store) : initialValue, setState]; +} + +export function useLockBodyScroll() { + React.useLayoutEffect(() => { + const originalStyle = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = originalStyle; + }; + }, []); +} + +export function useLongPress(callback, options = {}) { + const { threshold = 400, onStart, onFinish, onCancel } = options; + const isLongPressActive = React.useRef(false); + const isPressed = React.useRef(false); + const timerId = React.useRef(); + + return React.useMemo(() => { + if (typeof callback !== "function") { + return {}; + } + + const start = (event) => { + if (!isMouseEvent(event) && !isTouchEvent(event)) return; + + if (onStart) { + onStart(event); + } + + isPressed.current = true; + timerId.current = setTimeout(() => { + callback(event); + isLongPressActive.current = true; + }, threshold); + }; + + const cancel = (event) => { + if (!isMouseEvent(event) && !isTouchEvent(event)) return; + + if (isLongPressActive.current) { + if (onFinish) { + onFinish(event); + } + } else if (isPressed.current) { + if (onCancel) { + onCancel(event); + } + } + + isLongPressActive.current = false; + isPressed.current = false; + + if (timerId.current) { + window.clearTimeout(timerId.current); + } + }; + + const mouseHandlers = { + onMouseDown: start, + onMouseUp: cancel, + onMouseLeave: cancel, + }; + + const touchHandlers = { + onTouchStart: start, + onTouchEnd: cancel, + }; + + return { + ...mouseHandlers, + ...touchHandlers, + }; + }, [callback, threshold, onCancel, onFinish, onStart]); +} + +export function useMap(initialState) { + const mapRef = React.useRef(new Map(initialState)); + const [, reRender] = React.useReducer((x) => x + 1, 0); + + mapRef.current.set = (...args) => { + Map.prototype.set.apply(mapRef.current, args); + reRender(); + return mapRef.current; + }; + + mapRef.current.clear = (...args) => { + Map.prototype.clear.apply(mapRef.current, args); + reRender(); + }; + + mapRef.current.delete = (...args) => { + const res = Map.prototype.delete.apply(mapRef.current, args); + reRender(); + + return res; + }; + + return mapRef.current; +} + +export function useMeasure() { + const [dimensions, setDimensions] = React.useState({ + width: null, + height: null, + }); + + const previousObserver = React.useRef(null); + + const customRef = React.useCallback((node) => { + if (previousObserver.current) { + previousObserver.current.disconnect(); + previousObserver.current = null; + } + + if (node?.nodeType === Node.ELEMENT_NODE) { + const observer = new ResizeObserver(([entry]) => { + if (entry && entry.borderBoxSize) { + const { inlineSize: width, blockSize: height } = + entry.borderBoxSize[0]; + + setDimensions({ width, height }); + } + }); + + observer.observe(node); + previousObserver.current = observer; + } + }, []); + + return [customRef, dimensions]; +} + +export function useMediaQuery(query) { + const subscribe = React.useCallback( + (callback) => { + const matchMedia = window.matchMedia(query); + + matchMedia.addEventListener("change", callback); + return () => { + matchMedia.removeEventListener("change", callback); + }; + }, + [query] + ); + + const getSnapshot = () => { + return window.matchMedia(query).matches; + }; + + const getServerSnapshot = () => { + throw Error("useMediaQuery is a client-only hook"); + }; + + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +export function useMouse() { + const [state, setState] = React.useState({ + x: 0, + y: 0, + elementX: 0, + elementY: 0, + elementPositionX: 0, + elementPositionY: 0, + }); + + const ref = React.useRef(null); + + React.useLayoutEffect(() => { + const handleMouseMove = (event) => { + let newState = { + x: event.pageX, + y: event.pageY, + }; + + if (ref.current?.nodeType === Node.ELEMENT_NODE) { + const { left, top } = ref.current.getBoundingClientRect(); + const elementPositionX = left + window.scrollX; + const elementPositionY = top + window.scrollY; + const elementX = event.pageX - elementPositionX; + const elementY = event.pageY - elementPositionY; + + newState.elementX = elementX; + newState.elementY = elementY; + newState.elementPositionX = elementPositionX; + newState.elementPositionY = elementPositionY; + } + + setState((s) => { + return { + ...s, + ...newState, + }; + }); + }; + + document.addEventListener("mousemove", handleMouseMove); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + }; + }, []); + + return [state, ref]; +} + +const getConnection = () => { + return ( + navigator?.connection || + navigator?.mozConnection || + navigator?.webkitConnection + ); +}; + +const useNetworkStateSubscribe = (callback) => { + window.addEventListener("online", callback, { passive: true }); + window.addEventListener("offline", callback, { passive: true }); + + const connection = getConnection(); + + if (connection) { + connection.addEventListener("change", callback, { passive: true }); + } + + return () => { + window.removeEventListener("online", callback); + window.removeEventListener("offline", callback); + + if (connection) { + connection.removeEventListener("change", callback); + } + }; +}; + +const getNetworkStateServerSnapshot = () => { + throw Error("useNetworkState is a client-only hook"); +}; + +export function useNetworkState() { + const cache = React.useRef({}); + + const getSnapshot = () => { + const online = navigator.onLine; + const connection = getConnection(); + + const nextState = { + online, + downlink: connection?.downlink, + downlinkMax: connection?.downlinkMax, + effectiveType: connection?.effectiveType, + rtt: connection?.rtt, + saveData: connection?.saveData, + type: connection?.type, + }; + + if (isShallowEqual(cache.current, nextState)) { + return cache.current; + } else { + cache.current = nextState; + return nextState; + } + }; + + return React.useSyncExternalStore( + useNetworkStateSubscribe, + getSnapshot, + getNetworkStateServerSnapshot + ); +} + +export function useObjectState(initialValue) { + const [state, setState] = React.useState(initialValue); + + const handleUpdate = React.useCallback((arg) => { + if (typeof arg === "function") { + setState((s) => { + const newState = arg(s); + + if (isPlainObject(newState)) { + return { + ...s, + ...newState, + }; + } + }); + } + + if (isPlainObject(arg)) { + setState((s) => ({ + ...s, + ...arg, + })); + } + }, []); + + return [state, handleUpdate]; +} + +export function useOrientation() { + const [orientation, setOrientation] = React.useState({ + angle: 0, + type: "landscape-primary", + }); + + React.useLayoutEffect(() => { + const handleChange = () => { + const { angle, type } = window.screen.orientation; + setOrientation({ + angle, + type, + }); + }; + + const handle_orientationchange = () => { + setOrientation({ + type: "UNKNOWN", + angle: window.orientation, + }); + }; + + if (window.screen?.orientation) { + handleChange(); + window.screen.orientation.addEventListener("change", handleChange); + } else { + handle_orientationchange(); + window.addEventListener("orientationchange", handle_orientationchange); + } + + return () => { + if (window.screen?.orientation) { + window.screen.orientation.removeEventListener("change", handleChange); + } else { + window.removeEventListener( + "orientationchange", + handle_orientationchange + ); + } + }; + }, []); + + return orientation; +} + +const usePreferredLanguageSubscribe = (cb) => { + window.addEventListener("languagechange", cb); + return () => window.removeEventListener("languagechange", cb); +}; + +const getPreferredLanguageSnapshot = () => { + return navigator.language; +}; + +const getPreferredLanguageServerSnapshot = () => { + throw Error("usePreferredLanguage is a client-only hook"); +}; + +export function usePreferredLanguage() { + return React.useSyncExternalStore( + usePreferredLanguageSubscribe, + getPreferredLanguageSnapshot, + getPreferredLanguageServerSnapshot + ); +} + +export function usePrevious(value) { + const [current, setCurrent] = React.useState(value); + const [previous, setPrevious] = React.useState(null); + + if (value !== current) { + setPrevious(current); + setCurrent(value); + } + + return previous; +} + +export function useQueue(initialValue = []) { + const [queue, setQueue] = React.useState(initialValue); + + const add = React.useCallback((element) => { + setQueue((q) => [...q, element]); + }, []); + + const remove = React.useCallback(() => { + let removedElement; + + setQueue(([first, ...q]) => { + removedElement = first; + return q; + }); + + return removedElement; + }, []); + + const clear = React.useCallback(() => { + setQueue([]); + }, []); + + return { + add, + remove, + clear, + first: queue[0], + last: queue[queue.length - 1], + size: queue.length, + queue, + }; +} + +export function useRenderCount() { + const count = React.useRef(0); + + count.current++; + + return count.current; +} + +export function useRenderInfo(name = "Unknown") { + const count = React.useRef(0); + const lastRender = React.useRef(); + const now = Date.now(); + + count.current++; + + React.useEffect(() => { + lastRender.current = Date.now(); + }); + + const sinceLastRender = lastRender.current ? now - lastRender.current : 0; + + if (process.env.NODE_ENV !== "production") { + const info = { + name, + renders: count.current, + sinceLastRender, + timestamp: now, + }; + + console.log(info); + + return info; + } +} + +export function useScript(src, options = {}) { + const [status, setStatus] = React.useState("loading"); + const optionsRef = React.useRef(options); + + React.useEffect(() => { + let script = document.querySelector(`script[src="${src}"]`); + + const domStatus = script?.getAttribute("data-status"); + if (domStatus) { + setStatus(domStatus); + return; + } + + if (script === null) { + script = document.createElement("script"); + script.src = src; + script.async = true; + script.setAttribute("data-status", "loading"); + document.body.appendChild(script); + + const handleScriptLoad = () => { + script.setAttribute("data-status", "ready"); + setStatus("ready"); + removeEventListeners(); + }; + + const handleScriptError = () => { + script.setAttribute("data-status", "error"); + setStatus("error"); + removeEventListeners(); + }; + + const removeEventListeners = () => { + script.removeEventListener("load", handleScriptLoad); + script.removeEventListener("error", handleScriptError); + }; + + script.addEventListener("load", handleScriptLoad); + script.addEventListener("error", handleScriptError); + + const removeOnUnmount = optionsRef.current.removeOnUnmount; + + return () => { + if (removeOnUnmount === true) { + script.remove(); + removeEventListeners(); + } + }; + } else { + setStatus("unknown"); + } + }, [src]); + + return status; +} + +const setSessionStorageItem = (key, value) => { + const stringifiedValue = JSON.stringify(value); + window.sessionStorage.setItem(key, stringifiedValue); + dispatchStorageEvent(key, stringifiedValue); +}; + +const removeSessionStorageItem = (key) => { + window.sessionStorage.removeItem(key); + dispatchStorageEvent(key, null); +}; + +const getSessionStorageItem = (key) => { + return window.sessionStorage.getItem(key); +}; + +const useSessionStorageSubscribe = (callback) => { + window.addEventListener("storage", callback); + return () => window.removeEventListener("storage", callback); +}; + +const getSessionStorageServerSnapshot = () => { + throw Error("useSessionStorage is a client-only hook"); +}; + +export function useSessionStorage(key, initialValue) { + const getSnapshot = () => getSessionStorageItem(key); + + const store = React.useSyncExternalStore( + useSessionStorageSubscribe, + getSnapshot, + getSessionStorageServerSnapshot + ); + + const setState = React.useCallback( + (v) => { + try { + const nextState = typeof v === "function" ? v(JSON.parse(store)) : v; + + if (nextState === undefined || nextState === null) { + removeSessionStorageItem(key); + } else { + setSessionStorageItem(key, nextState); + } + } catch (e) { + console.warn(e); + } + }, + [key, store] + ); + + React.useEffect(() => { + if ( + getSessionStorageItem(key) === null && + typeof initialValue !== "undefined" + ) { + setSessionStorageItem(key, initialValue); + } + }, [key, initialValue]); + + return [store ? JSON.parse(store) : initialValue, setState]; +} + +export function useSet(values) { + const setRef = React.useRef(new Set(values)); + const [, reRender] = React.useReducer((x) => x + 1, 0); + + setRef.current.add = (...args) => { + const res = Set.prototype.add.apply(setRef.current, args); + reRender(); + + return res; + }; + + setRef.current.clear = (...args) => { + Set.prototype.clear.apply(setRef.current, args); + reRender(); + }; + + setRef.current.delete = (...args) => { + const res = Set.prototype.delete.apply(setRef.current, args); + reRender(); + + return res; + }; + + return setRef.current; +} + +export function useThrottle(value, interval = 500) { + const [throttledValue, setThrottledValue] = React.useState(value); + const lastUpdated = React.useRef(null); + + React.useEffect(() => { + const now = Date.now(); + + if (lastUpdated.current && now >= lastUpdated.current + interval) { + lastUpdated.current = now; + setThrottledValue(value); + } else { + const id = window.setTimeout(() => { + lastUpdated.current = now; + setThrottledValue(value); + }, interval); + + return () => window.clearTimeout(id); + } + }, [value, interval]); + + return throttledValue; +} + +export function useToggle(initialValue) { + const [on, setOn] = React.useState(() => { + if (typeof initialValue === "boolean") { + return initialValue; + } + + return Boolean(initialValue); + }); + + const handleToggle = React.useCallback((value) => { + if (typeof value === "boolean") { + return setOn(value); + } + + return setOn((v) => !v); + }, []); + + return [on, handleToggle]; +} + +const useVisibilityChangeSubscribe = (callback) => { + document.addEventListener("visibilitychange", callback); + + return () => { + document.removeEventListener("visibilitychange", callback); + }; +}; + +const getVisibilityChangeSnapshot = () => { + return document.visibilityState; +}; + +const getVisibilityChangeServerSnapshot = () => { + throw Error("useVisibilityChange is a client-only hook"); +}; + +export function useVisibilityChange() { + const visibilityState = React.useSyncExternalStore( + useVisibilityChangeSubscribe, + getVisibilityChangeSnapshot, + getVisibilityChangeServerSnapshot + ); + + return visibilityState === "visible"; +} + +export function useWindowScroll() { + const [state, setState] = React.useState({ + x: null, + y: null, + }); + + const scrollTo = React.useCallback((...args) => { + if (typeof args[0] === "object") { + window.scrollTo(args[0]); + } else if (typeof args[0] === "number" && typeof args[1] === "number") { + window.scrollTo(args[0], args[1]); + } else { + throw new Error( + `Invalid arguments passed to scrollTo. See here for more info. https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollTo` + ); + } + }, []); + + React.useLayoutEffect(() => { + const handleScroll = () => { + setState({ x: window.scrollX, y: window.scrollY }); + }; + + handleScroll(); + window.addEventListener("scroll", handleScroll); + + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, []); + + return [state, scrollTo]; +} + +export function useWindowSize() { + const [size, setSize] = React.useState({ + width: null, + height: null, + }); + + React.useLayoutEffect(() => { + const handleResize = () => { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }; + + handleResize(); + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + return size; +} diff --git a/mailchimp/index.js b/mailchimp/index.js deleted file mode 100755 index 6711ec6..0000000 --- a/mailchimp/index.js +++ /dev/null @@ -1,36 +0,0 @@ -//require("now-env"); For loading secrets in local dev: https://github.com/zeit/now-env -const { send, json } = require("micro"); - -const apiKey = process.env.mailchimp_api_key; -const listId = process.env.mailchimp_list_id; - -const mailchimp = new (require("mailchimp-api-v3"))(apiKey); - -module.exports = async (req, res) => { - const data = await json(req); - - mailchimp - .request({ - method: "POST", - path: `/lists/${listId}/members`, - body: { - email_address: data.email, - // Set status to "pending" (double-opt-in) or "subscribed" - status: "subscribed" - } - }) - .then(result => { - send(res, 200, { - success: true - }); - }) - .catch(err => { - console.log("Error", err); - send(res, err.status, { - error: true, - status: err.status, - title: err.title, - detail: err.detail - }); - }); -}; diff --git a/mailchimp/package.json b/mailchimp/package.json deleted file mode 100755 index 60fc7ec..0000000 --- a/mailchimp/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "main": "index.js", - "scripts": { - "start": "micro" - }, - "dependencies": { - "mailchimp-api-v3": "^1.12.1", - "micro": "^9.3.3", - "now-env": "^3.1.0" - } -} diff --git a/mailchimp/yarn.lock b/mailchimp/yarn.lock deleted file mode 100755 index 030e4d3..0000000 --- a/mailchimp/yarn.lock +++ /dev/null @@ -1,449 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -ajv@^6.5.5: - version "6.6.2" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.6.2.tgz#caceccf474bf3fc3ce3b147443711a24063cc30d" - dependencies: - fast-deep-equal "^2.0.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - dependencies: - color-convert "^1.9.0" - -arg@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/arg/-/arg-2.0.0.tgz#c06e7ff69ab05b3a4a03ebe0407fac4cba657545" - -asn1@~0.2.3: - version "0.2.4" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" - dependencies: - safer-buffer "~2.1.0" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - -aws-sign2@~0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" - -aws4@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" - -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - dependencies: - tweetnacl "^0.14.3" - -bluebird@^3.4.0: - version "3.5.3" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7" - -bytes@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" - -caseless@~0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" - -chalk@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.0.tgz#a060a297a6b57e15b61ca63ce84995daa0fe6e52" - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chownr@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494" - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - dependencies: - color-name "1.1.3" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - -combined-stream@^1.0.6, combined-stream@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" - dependencies: - delayed-stream "~1.0.0" - -content-type@1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - -core-util-is@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - dependencies: - assert-plus "^1.0.0" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - -depd@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" - -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - -extend@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" - -extsprintf@1.3.0, extsprintf@^1.2.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - -fast-deep-equal@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" - -fast-json-stable-stringify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" - -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - -form-data@~2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.6" - mime-types "^2.1.12" - -fs-minipass@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d" - dependencies: - minipass "^2.2.1" - -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - dependencies: - assert-plus "^1.0.0" - -har-schema@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - -har-validator@~5.1.0: - version "5.1.3" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" - dependencies: - ajv "^6.5.5" - har-schema "^2.0.0" - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - -http-errors@1.6.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" - dependencies: - depd "1.1.1" - inherits "2.0.3" - setprototypeof "1.0.3" - statuses ">= 1.3.1 < 2" - -http-signature@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - dependencies: - assert-plus "^1.0.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - -iconv-lite@0.4.19: - version "0.4.19" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" - -inherits@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - -is-stream@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - -is-typedarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - -json-schema@0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" - -json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - -jsprim@^1.2.2: - version "1.4.1" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" - dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.2.3" - verror "1.10.0" - -lodash@^4.17.10: - version "4.17.11" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" - -mailchimp-api-v3@^1.12.1: - version "1.12.1" - resolved "https://registry.yarnpkg.com/mailchimp-api-v3/-/mailchimp-api-v3-1.12.1.tgz#a147792e2b335da8c4345a24ae8aa4fec07f0abb" - dependencies: - bluebird "^3.4.0" - lodash "^4.17.10" - request "^2.88.0" - tar "^4.0.2" - -micro@^9.3.3: - version "9.3.3" - resolved "https://registry.yarnpkg.com/micro/-/micro-9.3.3.tgz#32728c7be15e807691ead85da27fd8117a8bca24" - dependencies: - arg "2.0.0" - chalk "2.4.0" - content-type "1.0.4" - is-stream "1.1.0" - raw-body "2.3.2" - -mime-db@~1.37.0: - version "1.37.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.37.0.tgz#0b6a0ce6fdbe9576e25f1f2d2fde8830dc0ad0d8" - -mime-types@^2.1.12, mime-types@~2.1.19: - version "2.1.21" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.21.tgz#28995aa1ecb770742fe6ae7e58f9181c744b3f96" - dependencies: - mime-db "~1.37.0" - -minimist@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - -minipass@^2.2.1, minipass@^2.3.4: - version "2.3.5" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848" - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - -minizlib@^1.1.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614" - dependencies: - minipass "^2.2.1" - -mkdirp@^0.5.0: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - dependencies: - minimist "0.0.8" - -now-env@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/now-env/-/now-env-3.1.0.tgz#e0198b67279d387229cfd4b25de4c2fc7156943f" - -oauth-sign@~0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" - -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - -psl@^1.1.24: - version "1.1.31" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184" - -punycode@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - -punycode@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" - -qs@~6.5.2: - version "6.5.2" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" - -raw-body@2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" - dependencies: - bytes "3.0.0" - http-errors "1.6.2" - iconv-lite "0.4.19" - unpipe "1.0.0" - -request@^2.88.0: - version "2.88.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~2.3.2" - har-validator "~5.1.0" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - oauth-sign "~0.9.0" - performance-now "^2.1.0" - qs "~6.5.2" - safe-buffer "^5.1.2" - tough-cookie "~2.4.3" - tunnel-agent "^0.6.0" - uuid "^3.3.2" - -safe-buffer@^5.0.1, safe-buffer@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - -safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - -setprototypeof@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" - -sshpk@^1.7.0: - version "1.16.0" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.0.tgz#1d4963a2fbffe58050aa9084ca20be81741c07de" - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - bcrypt-pbkdf "^1.0.0" - dashdash "^1.12.0" - ecc-jsbn "~0.1.1" - getpass "^0.1.1" - jsbn "~0.1.0" - safer-buffer "^2.0.2" - tweetnacl "~0.14.0" - -"statuses@>= 1.3.1 < 2": - version "1.5.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - dependencies: - has-flag "^3.0.0" - -tar@^4.0.2: - version "4.4.8" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d" - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.3.4" - minizlib "^1.1.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.2" - -tough-cookie@~2.4.3: - version "2.4.3" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" - dependencies: - psl "^1.1.24" - punycode "^1.4.1" - -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - dependencies: - safe-buffer "^5.0.1" - -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - -unpipe@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - -uri-js@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" - dependencies: - punycode "^2.1.0" - -uuid@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" - -verror@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - -yallist@^3.0.0, yallist@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9" diff --git a/now.json b/now.json deleted file mode 100755 index 5814f7d..0000000 --- a/now.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "version": 2, - "name": "usehooks", - "alias": "usehooks.com", - "env": { - "mailchimp_api_key": "@usehooks-mailchimp-api-key", - "mailchimp_list_id": "@usehooks-mailchimp-list-id" - }, - "builds": [ - { - "src": "package.json", - "use": "@now/static-build", - "config": { "distDir": "public" } - }, - { - "src": "mailchimp/index.js", - "use": "@now/node" - } - ], - "routes": [ - { - "src": "/api/subscribe", - "dest": "/mailchimp/index.js" - } - ] -} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d920bd2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,120 @@ +{ + "name": "@uidotdev/usehooks", + "version": "2.4.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@uidotdev/usehooks", + "version": "2.4.1", + "license": "MIT", + "devDependencies": { + "@types/react": "^18.2.20", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.1.6" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.2.20", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.20.tgz", + "integrity": "sha512-WKNtmsLWJM/3D5mG4U84cysVY31ivmyw85dE84fOCk5Hx78wezB/XEjVPWl2JTZ5FkEeaTJf+VgUAUn3PE7Isw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json old mode 100755 new mode 100644 index 4e3cb04..12908aa --- a/package.json +++ b/package.json @@ -1,34 +1,30 @@ { - "name": "usehooks", - "version": "1.0.0", - "author": "Gabe Ragland", - "dependencies": { - "babel-plugin-styled-components": "^1.10.0", - "gatsby": "^2.0.85", - "gatsby-plugin-feed": "^2.0.11", - "gatsby-plugin-react-helmet": "^3.0.5", - "gatsby-plugin-styled-components": "^3.0.4", - "gatsby-remark-external-links": "0.0.4", - "gatsby-source-filesystem": "^2.0.12", - "gatsby-transformer-remark": "^2.1.19", - "react": "^16.7.0-alpha.0", - "react-dom": "^16.7.0-alpha.0", - "react-helmet": "^5.2.0", - "react-syntax-highlighter": "^10.1.2", - "serve": "^10.1.1", - "styled-components": "^4.1.3", - "unfetch": "^4.0.1" + "name": "@uidotdev/usehooks", + "version": "2.4.1", + "description": "A collection of modern, server-safe React hooks – from the ui.dev team", + "type": "module", + "repository": "uidotdev/usehooks", + "devDependencies": { + "@types/react": "^18.2.20", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.1.6" }, - "license": "MIT", - "scripts": { - "build": "gatsby build", - "dev": "gatsby develop", - "start": "serve public/", - "deploy": "gatsby build --prefix-paths && gh-pages -d public", - "now-build": "npm run build" + "exports": { + "default": "./index.js" }, - "devDependencies": { - "gh-pages": "^2.0.1", - "prettier": "^1.14.2" - } + "engines": { + "node": ">=16" + }, + "files": [ + "index.js", + "index.d.ts" + ], + "types": "index.d.ts", + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "author": "Tyler McGinnis, Ben Adam", + "license": "MIT" } diff --git a/src/components/App.js b/src/components/App.js deleted file mode 100755 index 550c0b3..0000000 --- a/src/components/App.js +++ /dev/null @@ -1,63 +0,0 @@ -import React from "react"; -import Helmet from "react-helmet"; -import { StaticQuery, graphql, withPrefix } from "gatsby"; -import ogImage from "./../../static/img/og-image.png"; -import "./global.css"; - -const Layout = ({ children }) => ( - ( - <> - - - - - - - - - - - - - - - - - - {children} - - )} - /> -); - -export default Layout; diff --git a/src/components/EmailSignup.js b/src/components/EmailSignup.js deleted file mode 100755 index 8ee606f..0000000 --- a/src/components/EmailSignup.js +++ /dev/null @@ -1,95 +0,0 @@ -import React, { Fragment } from "react"; -import fetch from "unfetch"; -import styled from "styled-components"; - -class EmailSignup extends React.Component { - constructor(props) { - super(props); - this.state = { - subscribed: false, - emailInputValue: "" - }; - } - - subscribe = async email => { - this.setState({ subscribed: true }); - - const response = await new Promise((resolve, reject) => { - fetch("/api/subscribe", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ email: email }) - }) - .then(r => r.json()) - .then(data => { - resolve(data); - }); - }); - }; - render() { - const { subscribed } = this.state; - return ( -
-
- {subscribed ? ( -
- You are subscribed  🎉 -
- ) : ( - - 📩  Get new recipes in your inbox - -
{ - event.preventDefault(); - const email = this.state.emailInputValue; - if (email) { - this.subscribe(email); - } - }} - > -
-
- { - this.setState({ - emailInputValue: event.target.value - }); - }} - /> -
-
- -
-
-
- Join 1,651 subscribers. No spam ever. -
- )} -
-
- ); - } -} - -const Title = styled("div").attrs({ className: "subtitle is-5" })` - margin-bottom: 1.2rem; -`; - -const Extra = styled("div")` - margin-top: 1rem; - font-size: 0.8rem; - opacity: 0.7; -`; - -export default EmailSignup; diff --git a/src/components/Search.js b/src/components/Search.js deleted file mode 100755 index 93aec3c..0000000 --- a/src/components/Search.js +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; - -export default ({ value, onChange }) => { - return ( -
-
- onChange(value)} - /> - - - -
-
- ); -}; diff --git a/src/components/global.css b/src/components/global.css deleted file mode 100755 index e69de29..0000000 diff --git a/src/components/layout.js b/src/components/layout.js deleted file mode 100755 index e3661cc..0000000 --- a/src/components/layout.js +++ /dev/null @@ -1,149 +0,0 @@ -import React from "react"; -import { Link } from "gatsby"; -import styled from "styled-components"; -import App from "./App"; // Soon to be "App" -import EmailSignup from "./EmailSignup"; - -export const Layout = ({ children }) => { - return ( - - -
-
-

- - useHooks - ( - 🐠 - ) - -

-
- Easy to understand React Hook recipes by{" "} - - Gabe Ragland - -
-
-
- - - -
-
-
What all this about?
-

- Hooks are an upcoming feature that lets you use state and other - React features without writing a class. This websites provides - easy to understand code examples to help you understand how - hooks work and hopefully inspire you to take advantage of them - in your next project. Be sure to check out the{" "} - - official docs - - . -

-
- -
- {" "} - -
-
-
-
- -
- {children} -
- -
-
- - -
- / -
- -
- / -
- -
-
-
-
- ); -}; - -export default Layout; - -const GitHubLink = () => { - return ( - - - - ); -}; - -const Logo = styled(Link)` - text-decoration: none; - span { - opacity: 0.7; - } -`; - -const InfoSection = styled("section").attrs({ className: "section" })` - background-color: #e9fffc; -`; - -const Container = styled("div").attrs({ className: "container" })` - max-width: 960px !important; -`; - -const FooterLevel = styled("div").attrs({ className: "level" })` - margin: 20px auto 0 auto; - max-width: 150px; - span { - padding: 0 0.8rem; - opacity: 0.6; - } - a:hover { - text-decoration: underline; - } -`; diff --git a/src/pages/404.js b/src/pages/404.js deleted file mode 100755 index 1408235..0000000 --- a/src/pages/404.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from "react"; -import Layout from "../components/Layout"; - -const NotFoundPage = () => ( - -

NOT FOUND

-

This page doesn't exist

-
-); - -export default NotFoundPage; diff --git a/src/pages/index.js b/src/pages/index.js deleted file mode 100755 index 8518f6e..0000000 --- a/src/pages/index.js +++ /dev/null @@ -1,106 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { Link } from "gatsby"; -import Layout from "../components/Layout"; -import Search from "../components/Search"; -import { PostTemplate } from "../templates/post.js"; - -class IndexPage extends React.Component { - state = { search: "" }; - - render() { - const { data } = this.props; - const { search } = this.state; - const { edges: posts } = data.allMarkdownRemark; - - const filteredPosts = search ? searchPosts(posts, search) : posts; - - return ( - - {/* - this.setState({ search: value })} - /> - */} - - {filteredPosts.map(post => ( - - ))} - - ); - } -} - -export default IndexPage; - -export const pageQuery = graphql` - query IndexQuery { - allMarkdownRemark( - sort: { order: DESC, fields: [frontmatter___date] } - filter: { frontmatter: { templateKey: { eq: "post" } } } - ) { - edges { - node { - excerpt(pruneLength: 400) - id - html - fields { - slug - } - frontmatter { - templateKey - title - date(formatString: "MMMM DD, YYYY") - gist - sandbox - links { - url - name - description - } - code - } - } - } - } - } -`; - -// Quicky and hacky search -function searchPosts(posts, search) { - // Store title matches for quick lookup - let titles = {}; - - // Get all posts that have a matching title - const filterPostsByTitle = posts.filter(post => { - const hook = post.node.frontmatter; - const titleLowerCase = hook.title.toLowerCase(); - const doesInclude = titleLowerCase.includes(search.toLowerCase()); - if (doesInclude) { - titles[titleLowerCase] = true; - return true; - } else { - return false; - } - }); - - // Get all posts that have a matching description and DON'T match by title - const filterPostsByDescription = posts.filter(post => { - const hook = post.node.frontmatter; - const titleLowerCase = hook.title.toLowerCase(); - const description = post.node.html || ""; - return ( - !titles[titleLowerCase] && - description.toLowerCase().includes(search.toLowerCase()) - ); - }); - - // Add description matches to end of results - const filteredPosts = filterPostsByTitle.concat(filterPostsByDescription); - return filteredPosts; -} diff --git a/src/pages/useAnimation.md b/src/pages/useAnimation.md deleted file mode 100755 index 2110d6f..0000000 --- a/src/pages/useAnimation.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -templateKey: post -title: useAnimation -date: "2018-11-02" -gist: https://gist.github.com/gragland/28e628fb347afb908e945d43f9068d45 -sandbox: https://codesandbox.io/s/qxnmn1n45q -code: - "import { useState, useEffect } from 'react';\r\n\r\n// Usage\r\nfunction App() - {\r\n // Call hook multiple times to get animated values with different start delays\r\n - \ const animation1 = useAnimation('elastic', 600, 0);\r\n const animation2 = useAnimation('elastic', - 600, 150);\r\n const animation3 = useAnimation('elastic', 600, 300);\r\n\r\n return - (\r\n
\r\n \r\n\r\n \r\n\r\n \r\n
\r\n - \ );\r\n}\r\n\r\nconst Ball = ({ innerStyle }) => (\r\n \r\n);\r\n\r\n// Hook \r\nfunction useAnimation(\r\n easingName = 'linear',\r\n - \ duration = 500,\r\n delay = 0\r\n) {\r\n // The useAnimationTimer hook calls - useState every animation frame ...\r\n // ... giving us elapsed time and causing - a rerender as frequently ...\r\n // ... as possible for a smooth animation.\r\n - \ const elapsed = useAnimationTimer(duration, delay);\r\n // Amount of specified - duration elapsed on a scale from 0 - 1\r\n const n = Math.min(1, elapsed / duration);\r\n - \ // Return altered value based on our specified easing function\r\n return easing[easingName](n);\r\n}\r\n\r\n// - Some easing functions copied from:\r\n// https://github.com/streamich/ts-easing/blob/master/src/index.ts\r\n// - Hardcode here or pull in a dependency\r\nconst easing = {\r\n linear: n => n,\r\n - \ elastic: n =>\r\n n * (33 * n * n * n * n - 106 * n * n * n + 126 * n * n - - 67 * n + 15),\r\n inExpo: n => Math.pow(2, 10 * (n - 1))\r\n};\r\n\r\nfunction - useAnimationTimer(duration = 1000, delay = 0) {\r\n const [elapsed, setTime] = - useState(0);\r\n\r\n useEffect(\r\n () => {\r\n let animationFrame, timerStop, - start;\r\n\r\n // Function to be executed on each animation frame\r\n function - onFrame() {\r\n setTime(Date.now() - start);\r\n loop();\r\n }\r\n\r\n - \ // Call onFrame() on next animation frame\r\n function loop() {\r\n animationFrame - = requestAnimationFrame(onFrame);\r\n }\r\n\r\n function onStart() {\r\n - \ // Set a timeout to stop things when duration time elapses\r\n timerStop - = setTimeout(() => {\r\n cancelAnimationFrame(animationFrame);\r\n setTime(Date.now() - - start);\r\n }, duration);\r\n\r\n // Start the loop\r\n start - = Date.now();\r\n loop();\r\n }\r\n\r\n // Start after specified - delay (defaults to 0)\r\n const timerDelay = setTimeout(onStart, delay);\r\n\r\n - \ // Clean things up\r\n return () => {\r\n clearTimeout(timerStop);\r\n - \ clearTimeout(timerDelay);\r\n cancelAnimationFrame(animationFrame);\r\n - \ };\r\n },\r\n [duration, delay] // Only re-run effect if duration or - delay changes\r\n );\r\n\r\n return elapsed;\r\n}" ---- - -This hook allows you to smoothly animate any value using an easing function (linear, elastic, etc). In the example we call the useAnimation hook three times to animated three balls on to the screen at different intervals. Additionally we show how easy it is to compose hooks. Our useAnimation hook doesn't actual make use of useState or useEffect itself, but instead serves as a wrapper around the useAnimationTimer hook. Having the timer logic abstracted out into its own hook gives us better code readability and the ability to use timer logic in other contexts. Be sure to check out the [CodeSandbox Demo](https://codesandbox.io/s/qxnmn1n45q) for this one. diff --git a/src/pages/useDebounce.md b/src/pages/useDebounce.md deleted file mode 100755 index 4869623..0000000 --- a/src/pages/useDebounce.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -templateKey: post -title: useDebounce -date: "2018-11-09" -gist: https://gist.github.com/gragland/e50346f02e7edf4f81cc0bda33d3cae6 -sandbox: https://codesandbox.io/s/711r1zmq50 -code: - "import { useState, useEffect, useRef } from 'react';\r\n\r\n// Usage\r\nfunction - App() {\r\n // State and setters for ...\r\n // Search term\r\n const [searchTerm, - setSearchTerm] = useState('');\r\n // API search results\r\n const [results, setResults] - = useState([]);\r\n // Searching status (whether there is pending API request)\r\n - \ const [isSearching, setIsSearching] = useState(false);\r\n // Debounce search - term so that it only gives us latest value ...\r\n // ... if searchTerm has not - been updated within last 500ms.\r\n // The goal is to only have the API call fire - when user stops typing ...\r\n // ... so that we aren't hitting our API rapidly.\r\n - \ const debouncedSearchTerm = useDebounce(searchTerm, 500);\r\n \r\n // Effect - for API call \r\n useEffect(\r\n () => {\r\n if (debouncedSearchTerm) {\r\n - \ setIsSearching(true);\r\n searchCharacters(debouncedSearchTerm).then(results - => {\r\n setIsSearching(false);\r\n setResults(results.data.results);\r\n - \ });\r\n } else {\r\n setResults([]);\r\n }\r\n },\r\n - \ [debouncedSearchTerm] // Only call effect if debounced search term changes\r\n - \ );\r\n\r\n return (\r\n
\r\n setSearchTerm(e.target.value)}\r\n />\r\n - \ \r\n {isSearching &&
Searching ...
}\r\n\r\n {results.map(result - => (\r\n
\r\n

{result.title}

\r\n \r\n
\r\n ))}\r\n
\r\n - \ );\r\n}\r\n\r\n// API search function\r\nfunction searchCharacters(search) {\r\n - \ const apiKey = 'f9dfb1e8d466d36c27850bedd2047687';\r\n return fetch(\r\n `https://gateway.marvel.com/v1/public/comics?apikey=${apiKey}&titleStartsWith=${search}`,\r\n - \ {\r\n method: 'GET'\r\n }\r\n ).then(r => r.json());\r\n}\r\n \r\n// - Hook\r\nfunction useDebounce(value, delay) {\r\n // State and setters for debounced - value\r\n const [debouncedValue, setDebouncedValue] = useState(value);\r\n\r\n - \ useEffect(\r\n () => {\r\n // Update debounced value after delay\r\n const - handler = setTimeout(() => {\r\n setDebouncedValue(value);\r\n }, delay);\r\n\r\n - \ // Cancel the timeout if value changes (also on delay change or unmount)\r\n - \ // This is how we prevent debounced value from updating if value is changed - ...\r\n // .. within the delay period. Timeout gets cleared and restarted.\r\n - \ return () => {\r\n clearTimeout(handler);\r\n };\r\n },\r\n - \ [value, delay] // Only re-call effect if value or delay changes\r\n );\r\n\r\n - \ return debouncedValue;\r\n}" ---- - -This hook allows you to debounce any fast changing value. The debounced value will only reflect the latest value when the useDebounce hook has not been called for the specified time period. When used in conjuction with useEffect, as we do in the recipe below, you can easily ensure that expensive operations like API calls are not executed too frequently. The example below allows you to search the Marvel Comic API and uses useDebounce to prevent API calls from being fired on every keystroke. Be sure to theck out the [CodeSandbox demo](https://codesandbox.io/s/711r1zmq50) for this one. Hook code and inspiration from [github.com/xnimorz/use-debounce](https://github.com/xnimorz/use-debounce). diff --git a/src/pages/useHistory.md b/src/pages/useHistory.md deleted file mode 100755 index 58aa361..0000000 --- a/src/pages/useHistory.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -templateKey: post -title: useHistory -date: "2018-11-19" -gist: https://gist.github.com/gragland/d48cca2b26bcd93f453054554fc892bf -sandbox: https://codesandbox.io/s/32rqn6zq0p -links: - - url: https://github.com/xxhomey19/use-undo - name: xxhomey19/use-undo - description: - The library that this code was copied from with minor changes. Also - returns previous and future states from hook, but doesn't have a clear action. - - url: https://codesandbox.io/s/yv3004lqnj - name: React useHistory hook - description: An alternate implementation of useHistory by @juice49. -code: - "import { useReducer, useCallback } from 'react';\r\n\r\n// Usage\r\nfunction - App() {\r\n const { state, set, undo, redo, clear, canUndo, canRedo } = useHistory({});\r\n\r\n - \ return (\r\n
\r\n
\r\n - \
\U0001F469‍\U0001F3A8 Click squares to draw
\r\n - \ \r\n - \ \r\n - \ \r\n
\r\n\r\n
\r\n {((blocks, i, len) => {\r\n // Generate - a grid of blocks\r\n while (++i <= len) {\r\n const index = - i;\r\n blocks.push(\r\n set({ - ...state, [index]: !state[index] })}\r\n key={i}\r\n />\r\n - \ );\r\n }\r\n return blocks;\r\n })([], 0, 625)}\r\n - \
\r\n
\r\n );\r\n}\r\n\r\n// Initial state that we pass into - useReducer\r\nconst initialState = {\r\n // Array of previous state values updated - each time we push a new state\r\n past: [],\r\n // Current state value\r\n present: - null,\r\n // Will contain \"future\" state values if we undo (so we can redo)\r\n - \ future: []\r\n};\r\n\r\n// Our reducer function to handle state changes based - on action\r\nconst reducer = (state, action) => {\r\n const { past, present, future - } = state;\r\n\r\n switch (action.type) {\r\n case 'UNDO':\r\n const previous - = past[past.length - 1];\r\n const newPast = past.slice(0, past.length - 1);\r\n\r\n - \ return {\r\n past: newPast,\r\n present: previous,\r\n future: - [present, ...future]\r\n };\r\n case 'REDO':\r\n const next = future[0];\r\n - \ const newFuture = future.slice(1);\r\n\r\n return {\r\n past: - [...past, present],\r\n present: next,\r\n future: newFuture\r\n };\r\n - \ case 'SET':\r\n const { newPresent } = action;\r\n\r\n if (newPresent - === present) {\r\n return state;\r\n }\r\n return {\r\n past: - [...past, present],\r\n present: newPresent,\r\n future: []\r\n };\r\n - \ case 'CLEAR':\r\n const { initialPresent } = action;\r\n\r\n return - {\r\n ...initialState,\r\n present: initialPresent\r\n };\r\n - \ }\r\n};\r\n\r\n// Hook\r\nconst useHistory = initialPresent => {\r\n const [state, - dispatch] = useReducer(reducer, {\r\n ...initialState,\r\n present: initialPresent\r\n - \ });\r\n\r\n const canUndo = state.past.length !== 0;\r\n const canRedo = state.future.length - !== 0;\r\n\r\n // Setup our callback functions\r\n // We memoize with useCallback - to prevent unecessary re-renders\r\n\r\n const undo = useCallback(\r\n () => - {\r\n if (canUndo) {\r\n dispatch({ type: 'UNDO' });\r\n }\r\n - \ },\r\n [canUndo, dispatch]\r\n );\r\n\r\n const redo = useCallback(\r\n - \ () => {\r\n if (canRedo) {\r\n dispatch({ type: 'REDO' });\r\n }\r\n - \ },\r\n [canRedo, dispatch]\r\n );\r\n\r\n const set = useCallback(newPresent - => dispatch({ type: 'SET', newPresent }), [\r\n dispatch\r\n ]);\r\n\r\n const - clear = useCallback(() => dispatch({ type: 'CLEAR', initialPresent }), [\r\n dispatch\r\n - \ ]);\r\n\r\n // If needed we could also return past and future state\r\n return - { state: state.present, set, undo, redo, clear, canUndo, canRedo };\r\n};" ---- - -This hook makes it really easy to add undo/redo functionality to your app. Our recipe is a simple drawing app. It generates a grid of blocks, allows you to click any block to toggle its color, and uses the useHistory hook so we can undo, redo, or clear all changes to the canvas. Check out our [CodeSandbox demo](https://codesandbox.io/s/32rqn6zq0p). Within our hook we're using useReducer to store state instead of useState, which should look familiar to anyone that's used redux (read more about useReducer in the [official docs](https://reactjs.org/docs/hooks-reference.html#usereducer)). The hook code was copied, with minor changes, from the excellent [use-undo library](https://github.com/xxhomey19/use-undo), so if you'd like to pull this into your project you can also use that library via npm. diff --git a/src/pages/useHover.md b/src/pages/useHover.md deleted file mode 100755 index daac4b0..0000000 --- a/src/pages/useHover.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -templateKey: post -title: useHover -date: "2018-10-30" -gist: https://gist.github.com/gragland/cfc4089e2f5d98dde5033adc44da53f8 -sandbox: https://codesandbox.io/s/01w2zmj010 -code: - "\r\nimport { useRef, useState, useEffect } from 'react';\r\n\r\n// Usage\r\nfunction - App() {\r\n const [hoverRef, isHovered] = useHover();\r\n\r\n return (\r\n
\r\n {isHovered ? '\U0001F601' : '☹️'}\r\n
\r\n );\r\n}\r\n\r\n// - Hook\r\nfunction useHover() {\r\n const [value, setValue] = useState(false);\r\n\r\n - \ const ref = useRef(null);\r\n\r\n const handleMouseOver = () => setValue(true);\r\n - \ const handleMouseOut = () => setValue(false);\r\n\r\n useEffect(\r\n () => - {\r\n const node = ref.current;\r\n if (node) {\r\n node.addEventListener('mouseover', - handleMouseOver);\r\n node.addEventListener('mouseout', handleMouseOut);\r\n\r\n - \ return () => {\r\n node.removeEventListener('mouseover', handleMouseOver);\r\n - \ node.removeEventListener('mouseout', handleMouseOut);\r\n };\r\n - \ }\r\n },\r\n [ref.current] // Recall only if ref changes\r\n );\r\n\r\n - \ return [ref, value];\r\n}" ---- - -Detect whether the mouse is hovering an element. The hook returns a ref -and a boolean value indicating whether the element with that ref is currently being -hovered. So just add the returned ref to any element whose hover state you want -to monitor. diff --git a/src/pages/useKeyPress.md b/src/pages/useKeyPress.md deleted file mode 100755 index 8c3b428..0000000 --- a/src/pages/useKeyPress.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -templateKey: post -title: useKeyPress -date: "2018-11-14" -gist: https://gist.github.com/gragland/b61b8f46114edbcf2a9e4bd5eb9f47f5 -sandbox: https://codesandbox.io/s/5v71vl72kk -tags: [] -links: - - url: https://codesandbox.io/s/y3qzyr3lrz - name: useMultiKeyPress - description: - A fork of this recipe by @jhsu - that detects multiple keys at once. -code: - "import { useState, useEffect } from 'react';\r\n\r\n// Usage\r\nfunction App() - {\r\n // Call our hook for each key that we'd like to monitor\r\n const happyPress - = useKeyPress('h');\r\n const sadPress = useKeyPress('s');\r\n const robotPress - = useKeyPress('r');\r\n const foxPress = useKeyPress('f');\r\n\r\n return (\r\n - \
\r\n
h, s, r, f
\r\n
\r\n {happyPress && - '\U0001F60A'}\r\n {sadPress && '\U0001F622'}\r\n {robotPress && '\U0001F916'}\r\n - \ {foxPress && '\U0001F98A'}\r\n
\r\n
\r\n );\r\n}\r\n\r\n// - Hook\r\nfunction useKeyPress(targetKey) {\r\n // State for keeping track of whether - key is pressed\r\n const [keyPressed, setKeyPressed] = useState(false);\r\n\r\n - \ // If pressed key is our target key then set to true\r\n function downHandler({ - key }) {\r\n if (key === targetKey) {\r\n setKeyPressed(true);\r\n }\r\n - \ }\r\n\r\n // If released key is our target key then set to false\r\n const upHandler - = ({ key }) => {\r\n if (key === targetKey) {\r\n setKeyPressed(false);\r\n - \ }\r\n };\r\n\r\n // Add event listeners\r\n useEffect(() => {\r\n window.addEventListener('keydown', - downHandler);\r\n window.addEventListener('keyup', upHandler);\r\n // Remove - event listeners on cleanup\r\n return () => {\r\n window.removeEventListener('keydown', - downHandler);\r\n window.removeEventListener('keyup', upHandler);\r\n };\r\n - \ }, []); // Empty array ensures that effect is only run on mount and unmount\r\n\r\n - \ return keyPressed;\r\n}" ---- - -This hook makes it easy to detect when the user is pressing a specific key on their keyboard. The recipe is fairly simple, as I want to show how little code is required, but I challenge any readers to create a more advanced version of this hook. Detecting when multiple keys are held down at the same time would be a nice addition. Bonus points: also require they be held down in a specified order. Feel free to share anything you've created in this [recipe's gist](https://gist.github.com/gragland/b61b8f46114edbcf2a9e4bd5eb9f47f5). diff --git a/src/pages/useLocalStorage.md b/src/pages/useLocalStorage.md deleted file mode 100755 index 05a4b12..0000000 --- a/src/pages/useLocalStorage.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -templateKey: post -title: useLocalStorage -date: "2018-10-29" -gist: https://gist.github.com/gragland/2970ae543df237a07be1dbbf810f23fe -sandbox: https://codesandbox.io/s/qxkr4mplv6 -code: - "import { useState, useEffect } from 'react';\r\n\r\n// Usage\r\nfunction App() - {\r\n // Similar to useState but we pass in a key to value in local storage\r\n - \ // With useState: const [name, setName] = useState('Bob');\r\n const [name, setName] - = useLocalStorage('name', 'Bob');\r\n\r\n return (\r\n
\r\n setName(e.target.value)}\r\n />\r\n
\r\n );\r\n}\r\n\r\n// - Hook\r\nfunction useLocalStorage(key, initialValue) {\r\n // The initialValue arg - is only used if there is nothing in localStorage ...\r\n // ... otherwise we use - the value in localStorage so state persist through a page refresh.\r\n // We pass - a function to useState so localStorage lookup only happens once.\r\n // We wrap - in try/catch in case localStorage is unavailable\r\n const [item, setInnerValue] - = useState(() => {\r\n try {\r\n return window.localStorage.getItem(key)\r\n - \ ? JSON.parse(window.localStorage.getItem(key))\r\n : initialValue;\r\n - \ } catch (error) {\r\n // Return default value if JSON parsing fails\r\n - \ return initialValue;\r\n }\r\n });\r\n\r\n // Return a wrapped version - of useState's setter function that ...\r\n // ... persists the new value to localStorage.\r\n - \ const setValue = value => {\r\n setInnerValue(value);\r\n window.localStorage.setItem(key, - JSON.stringify(item));\r\n };\r\n\r\n // Alternatively we could update localStorage - inside useEffect ...\r\n // ... but this would run every render and it really only - needs ...\r\n // ... to happen when the returned setValue function is called.\r\n - \ /*\r\n useEffect(() => {\r\n window.localStorage.setItem(key, JSON.stringify(item));\r\n - \ });\r\n */\r\n\r\n return [item, setValue];\r\n}" ---- - -Sync state to local storage so that it persists through a page refresh. -Usage is similar to useState except we pass in a local storage key so that we can -default to that value on page load instead of the specified initial value. diff --git a/src/pages/useLockBodyScroll.md b/src/pages/useLockBodyScroll.md deleted file mode 100644 index fb7b551..0000000 --- a/src/pages/useLockBodyScroll.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -templateKey: post -title: useLockBodyScroll -date: "2019-01-15" -gist: https://gist.github.com/gragland/f50690d2724aec1bd513de8596dcd9b9 -sandbox: https://codesandbox.io/s/yvkol51m81 -links: - - url: https://jeremenichelli.io/2019/01/how-hooks-might-shape-design-systems-built-in-react/ - name: How hooks might shape design systems built in React - description: Great blog post that inspired this hook recipe. Their version of the useLockBodyScroll hook accepts a toggle argument to give more control over lock state. -code: "import { useState, useLayoutEffect } from 'react';\r\n\r\n\/\/ Usage\r\nconst App(){\r\n \/\/ State for our modal\r\n const [modalOpen, setModalOpen] = useState(false);\r\n \r\n return (\r\n
\r\n \r\n\r\n

Increment - a counter (fast ⚡️)

\r\n

Counter: {count}

\r\n \r\n
\r\n );\r\n}" ---- - -React has a built-in hook called useMemo that allows you to memoize expensive functions so that you can avoid calling them on every render. You simple pass in a function and an array of inputs and useMemo will only recompute the memoized value when one of the inputs has changed. In our example below we have an expensive function called computeLetterCount (for demo purposes we make it slow by including a large and completely unnecessary loop). When the current selected word changes you'll notice a delay as it has to recall computeLetterCount on the new word. We also have a separate counter that gets incremented everytime the increment button is clicked. When that counter is incremented you'll notice that there is zero lag between renders. This is because computeLetterCount is not called again. The input word hasn't changed and thus the cached value is returned. You'll probably want to check out the [CodeSandbox demo](https://codesandbox.io/s/jjxypyk86w) so you can see for yourself. diff --git a/src/pages/useOnClickOutside.md b/src/pages/useOnClickOutside.md deleted file mode 100755 index 92863b9..0000000 --- a/src/pages/useOnClickOutside.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -templateKey: post -title: useOnClickOutside -date: "2018-11-05" -gist: https://gist.github.com/gragland/81a678775c30edfdbb224243fc0d1ec4 -sandbox: https://codesandbox.io/s/23jk7wlw4y -links: - - url: https://github.com/Andarist/use-onclickoutside - name: Andarist/use-onclickoutside - description: Similar logic implemented as a library. Also accounts for passive events. Good choice if you want to pull something from github/npm. -code: "import { useState, useEffect, useRef } from 'react';\r\n\r\n\/\/ Usage\r\nfunction App() {\r\n \/\/ Create a ref that we add to the element for which we want to detect outside clicks\r\n const ref = useRef();\r\n \/\/ State for our modal\r\n const [isModalOpen, setModalOpen] = useState(false);\r\n \/\/ Call hook passing in the ref and a function to call on outside click\r\n useOnClickOutside(ref, () => setModalOpen(false));\r\n\r\n return (\r\n
\r\n {isModalOpen ? (\r\n
\r\n \uD83D\uDC4B Hey, I'm a modal. Click anywhere outside of me to close.\r\n <\/div>\r\n ) : (\r\n
\r\n - \ );\r\n}\r\n\r\n// Hook\r\nfunction useOnScreen(ref, rootMargin = '0px') {\r\n - \ // State and setter for storing whether element is visible\r\n const [isIntersecting, - setIntersecting] = useState(false);\r\n\r\n useEffect(() => {\r\n const observer - = new IntersectionObserver(\r\n ([entry]) => {\r\n // Update our state - when observer callback fires\r\n setIntersecting(entry.isIntersecting);\r\n - \ },\r\n {\r\n rootMargin\r\n }\r\n );\r\n if (ref.current) - {\r\n observer.observe(ref.current);\r\n }\r\n return () => {\r\n observer.unobserve(ref.current);\r\n - \ };\r\n }, []); // Empty array ensures that effect is only run on mount and - unmount\r\n\r\n return isIntersecting;\r\n}" ---- - -This hook allows you to easily detect when an element is visible on the -screen as well as specify how much of the element should be visible before being -considered on screen. Perfect for lazy loading images or triggering animations when -the user has scrolled down to a particular section. diff --git a/src/pages/usePrevious.md b/src/pages/usePrevious.md deleted file mode 100755 index a452439..0000000 --- a/src/pages/usePrevious.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -templateKey: post -title: usePrevious -date: "2018-11-07" -gist: https://gist.github.com/gragland/1ed713a68c770ea414c3b92ccf2bdd2f -sandbox: https://codesandbox.io/s/pwnl6v7z6m -code: - "import { useState, useEffect, useRef } from 'react';\r\n\r\n// Usage\r\nfunction - App() {\r\n // State value and setter for our example\r\n const [count, setCount] - = useState(0);\r\n \r\n // Get the previous value (was passed into hook on last - render)\r\n const prevCount = usePrevious(count);\r\n \r\n // Display both current - and previous count value\r\n return (\r\n
\r\n

Now: {count}, before: - {prevCount}

\r\n \r\n - \
\r\n );\r\n}\r\n\r\n// Hook\r\nfunction usePrevious(value) {\r\n // - The ref object is a generic container whose current property is mutable ...\r\n - \ // ... and can hold any value, similar to an instance property on a class\r\n - \ const ref = useRef();\r\n \r\n // Store current value in ref\r\n useEffect(() - => {\r\n ref.current = value;\r\n }, [value]); // Only re-run if value changes\r\n - \ \r\n // Return previous value (happens before update in useEffect above)\r\n - \ return ref.current;\r\n}" ---- - -One question that comes up a lot is _"When using hooks how do I get the previous value of props or state?"_. With React class components you have the componentDidUpdate method which receives previous props and state as arguments or you can update an instance variable (this.previous = value) and reference it later to get the previous value. So how can we do this inside a functional component that doesn't have lifecycle methods or an instance to store values on? Hooks to the rescue! We can create a custom hook that uses the useRef hook internally for storing the previous value. See the recipe below with inline comments. You can also find this example in the official [React Hooks FAQ](https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state). diff --git a/src/pages/useScript.md b/src/pages/useScript.md deleted file mode 100755 index 700b441..0000000 --- a/src/pages/useScript.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -templateKey: post -title: useScript -date: "2018-11-15" -gist: https://gist.github.com/gragland/929e42759c0051ff596bc961fb13cd93 -sandbox: https://codesandbox.io/s/pm28k14qlj -links: - - url: https://github.com/sesilio/react-script-loader-hoc/blob/master/src/index.js - name: react-script-loader-hoc - description: HOC implemantion of same logic for the sake of comparison. - - url: https://github.com/palmerhq/the-platform#usescript - name: useScript from palmerhq/the-platform - description: Similar hook but returns a promise for use with React Suspense. -code: - "import { useState, useEffect } from 'react';\r\n\r\n// Usage\r\nfunction App() - {\r\n const [loaded, error] = useScript(\r\n 'https://pm28k14qlj.codesandbox.io/test-external-script.js'\r\n - \ );\r\n\r\n return (\r\n
\r\n
\r\n Script loaded: {loaded.toString()}\r\n - \
\r\n {loaded && !error && (\r\n
\r\n Script - function call response: {TEST_SCRIPT.start()}\r\n
\r\n )}\r\n - \
\r\n );\r\n}\r\n\r\n// Hook\r\nlet cachedScripts = [];\r\nfunction useScript(src) - {\r\n // Keeping track of script loaded and error state\r\n const [state, setState] - = useState({\r\n loaded: false,\r\n error: false\r\n });\r\n\r\n useEffect(\r\n - \ () => {\r\n // If cachedScripts array already includes src that means another - instance ...\r\n // ... of this hook already loaded this script, so no need - to load again.\r\n if (cachedScripts.includes(src)) {\r\n setState({\r\n - \ loaded: true,\r\n error: false\r\n });\r\n } else - {\r\n cachedScripts.push(src);\r\n\r\n // Create script\r\n let - script = document.createElement('script');\r\n script.src = src;\r\n script.async - = true;\r\n\r\n // Script event listener callbacks for load and error\r\n - \ const onScriptLoad = () => {\r\n setState({\r\n loaded: - true,\r\n error: false\r\n });\r\n };\r\n\r\n const - onScriptError = () => {\r\n // Remove from cachedScripts we can try loading - again\r\n const index = cachedScripts.indexOf(src);\r\n if (index - >= 0) cachedScripts.splice(index, 1);\r\n script.remove();\r\n\r\n setState({\r\n - \ loaded: true,\r\n error: true\r\n });\r\n };\r\n\r\n - \ script.addEventListener('load', onScriptLoad);\r\n script.addEventListener('error', - onScriptError);\r\n\r\n // Add script to document body\r\n document.body.appendChild(script);\r\n\r\n - \ // Remove event listeners on cleanup\r\n return () => {\r\n script.removeEventListener('load', - onScriptLoad);\r\n script.removeEventListener('error', onScriptError);\r\n - \ };\r\n }\r\n },\r\n [src] // Only re-run effect if script src - changes\r\n );\r\n\r\n return [state.loaded, state.error];\r\n}" ---- - -This hook makes it super easy to dynamically load an external script and know when its loaded. This is useful when you need to interact with a 3rd party libary (Stripe, Google Analytics, etc) and you'd prefer to load the script when needed rather then include it in the document head for every page request. In the example below we wait until the script has loaded successfully before calling a function declared in the script. If you're interested in seeing how this would look if implemented as a Higher Order Component then check out the [source of react-script-loader-hoc](https://github.com/sesilio/react-script-loader-hoc/blob/master/src/index.js). I personally find it much more readable as a hook. Another advantage is because it's so easy to call the same hook multiple times to load multiple different scripts, unlike the HOC implementation, we can skip adding support for passing in multiple src strings. diff --git a/src/pages/useSpring.md b/src/pages/useSpring.md deleted file mode 100755 index 8796061..0000000 --- a/src/pages/useSpring.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -templateKey: post -title: useSpring -date: "2018-11-21" -gist: https://gist.github.com/gragland/7556098f208ffd233b0a906cbc569110 -sandbox: https://codesandbox.io/s/6jlvz1j5q3 -links: - - url: http://react-spring.surge.sh - name: react-spring - description: Offical docs with lots of fun animation examples. See section about the useSpring hook here. - - url: https://codesandbox.io/s/j1zol1nrq3 - name: Card Demo - description: Original useSpring demo that my code is based on by 0xca0a. - - url: https://codesandbox.io/s/py912w5k6m - name: Scroll Animation Demo - description: Another useSpring demo that animates on scroll by 0xca0a. - - url: https://usehooks.com/#useAnimation - target: _self - name: useAnimation - description: Animation hook recipe that I previously posted with no dependencies. Won't be as performant and is time-based rather than physics-based. -code: "import { useState, useRef } from 'react';\r\nimport { useSpring, animated } from 'react-spring';\r\n\r\n// Displays a row of cards\r\n// Usage of hook is within component below\r\nfunction App() {\r\n return (\r\n
\r\n
\r\n {cards.map((card, i) => (\r\n
\r\n \r\n
{card.title}
\r\n
{card.description}
\r\n \r\n
\r\n
\r\n ))}\r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction Card({ children }) {\r\n // We add this ref to card element and use in onMouseMove event ...\r\n // ... to get element's offset and dimensions.\r\n const ref = useRef();\r\n \r\n // Keep track of whether card is hovered so we can increment ...\r\n // ... zIndex to ensure it shows up above other cards when animation causes overlap.\r\n const [isHovered, setHovered] = useState(false);\r\n \r\n // The useSpring hook\r\n const [animatedProps, setAnimatedProps] = useSpring({\r\n // Array containing [rotateX, rotateY, and scale] values.\r\n // We store under a single key (xys) instead of separate keys ...\r\n // ... so that we can use animatedProps.xys.interpolate() to ...\r\n // ... easily generate the css transform value below.\r\n xys: [0, 0, 1],\r\n // Setup physics\r\n config: { mass: 10, tension: 400, friction: 40, precision: 0.00001 }\r\n });\r\n\r\n return (\r\n setHovered(true)}\r\n onMouseMove={({ clientX, clientY }) => {\r\n // Get mouse x position within card\r\n const x =\r\n clientX -\r\n (ref.current.offsetLeft -\r\n (window.scrollX || window.pageXOffset || document.body.scrollLeft));\r\n\r\n // Get mouse y position within card\r\n const y =\r\n clientY -\r\n (ref.current.offsetTop -\r\n (window.scrollY || window.pageYOffset || document.body.scrollTop));\r\n\r\n // Set animated values based on mouse position and card dimensions\r\n const dampen = 50; // Lower the number the less rotation\r\n const xys = [\r\n -(y - ref.current.clientHeight / 2) / dampen, // rotateX\r\n (x - ref.current.clientWidth / 2) / dampen, // rotateY\r\n 1.07 // Scale\r\n ];\r\n \r\n // Update values to animate to\r\n setAnimatedProps({ xys: xys });\r\n }}\r\n onMouseLeave={() => {\r\n setHovered(false);\r\n // Set xys back to original\r\n setAnimatedProps({ xys: [0, 0, 1] });\r\n }}\r\n style={{\r\n // If hovered we want it to overlap other cards when it scales up\r\n zIndex: isHovered ? 2 : 1,\r\n // Interpolate function to handle css changes\r\n transform: animatedProps.xys.interpolate(\r\n (x, y, s) =>\r\n `perspective(600px) rotateX(${x}deg) rotateY(${y}deg) scale(${s})`\r\n )\r\n }}\r\n >\r\n {children}\r\n \r\n );\r\n}" ---- - -This hook is part of the [react-spring](https://github.com/drcmda/react-spring) animation library which allows for highly performant physics-based animations. I try to avoid including dependencies in these recipes, but once in awhile I'm going to make an exception for hooks that expose the functionality of **really** useful libraries. One nice thing about react-spring is that it allows you to completely skip the React render cycle when applying animations, often giving a pretty substantial performance boost. In our recipe below we render a row of cards and apply a springy animation effect related to the mouse position over any given card. To make this work we call the useSpring hook with an array of values we want to animate, render an animated.div component (exported by react-spring), get the mouse position over a card with the onMouseMove event, then call setAnimatedProps (function returned by the hook) to update that set of values based on the mouse position. Read through the comments in the recipe below for more details or jump right over to the [CodeSandbox demo](https://codesandbox.io/s/6jlvz1j5q3). I liked this effect so much I ended up using it on my [startup's landing page](https://divjoy.com) 😎 diff --git a/src/pages/useTheme.md b/src/pages/useTheme.md deleted file mode 100755 index 6373082..0000000 --- a/src/pages/useTheme.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -templateKey: post -title: useTheme -date: "2019-01-07" -gist: https://gist.github.com/gragland/509efd16c695e7817eb70921c77c8a05 -sandbox: https://codesandbox.io/s/15mko9187 -links: - - url: https://medium.com/geckoboard-under-the-hood/how-we-made-our-product-more-personalized-with-css-variables-and-react-b29298fde608 - name: CSS Variables and React - description: The blog post by Dan Bahrami that inspired this recipe. -code: "import { useLayoutEffect } from 'react';\r\nimport '.\/styles.scss'; \/\/ -> https:\/\/codesandbox.io\/s\/15mko9187\r\n\r\n\/\/ Usage\r\nconst theme = {\r\n 'button-padding': '16px',\r\n 'button-font-size': '14px',\r\n 'button-border-radius': '4px',\r\n 'button-border': 'none',\r\n 'button-color': '#FFF',\r\n 'button-background': '#6772e5',\r\n 'button-hover-border': 'none',\r\n 'button-hover-color': '#FFF'\r\n};\r\n\r\nfunction App() {\r\n useTheme(theme);\r\n\r\n return (\r\n
\r\n } + + diff --git a/usehooks.com/src/components/CodePreview.astro b/usehooks.com/src/components/CodePreview.astro new file mode 100644 index 0000000..be8eaff --- /dev/null +++ b/usehooks.com/src/components/CodePreview.astro @@ -0,0 +1,14 @@ +--- +import CodePreview from "./codepreview/CodePreview"; +import { getFiles } from "./codepreview/utils"; + +const { sandboxId, previewHeight } = Astro.props; +const files = await getFiles({ id: sandboxId }); +--- + +
+

Demo:

+
+ +
+
diff --git a/usehooks.com/src/components/CountdownTimer.tsx b/usehooks.com/src/components/CountdownTimer.tsx new file mode 100644 index 0000000..72ea4d4 --- /dev/null +++ b/usehooks.com/src/components/CountdownTimer.tsx @@ -0,0 +1,89 @@ +import { Fragment, useEffect, useState } from "react"; + +interface CountdownProps { + targetDate: string; // YYYY-MM-DD format +} + +interface TimeLeft { + days: number; + hours: number; + minutes: number; + seconds: number; +} + +function calculateTimeLeft(targetDate: string): TimeLeft { + const target = new Date(`${targetDate}T00:00:00-08:00`); + const now = new Date(); + const difference = +target - +now; + + if (difference <= 0) { + return { + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }; + } + + return { + days: Math.floor(difference / (1000 * 60 * 60 * 24)), + hours: Math.floor((difference / (1000 * 60 * 60)) % 24), + minutes: Math.floor((difference / 1000 / 60) % 60), + seconds: Math.floor((difference / 1000) % 60), + }; +} + +const formatNumber = (number: number) => number.toString().padStart(2, "0"); + +const Countdown: React.FC = ({ targetDate }) => { + const [timeLeft, setTimeLeft] = useState( + calculateTimeLeft(targetDate) + ); + + useEffect(() => { + const timer = setInterval(() => { + const newTimeLeft = calculateTimeLeft(targetDate); + setTimeLeft(newTimeLeft); + if ( + newTimeLeft.days === 0 && + newTimeLeft.hours === 0 && + newTimeLeft.minutes === 0 && + newTimeLeft.seconds === 0 + ) { + clearInterval(timer); + } + }, 1000); + + return () => clearInterval(timer); + }, [targetDate]); + + if ( + timeLeft.days === 0 && + timeLeft.hours === 0 && + timeLeft.minutes === 0 && + timeLeft.seconds === 0 + ) { + return null; + } + + return ( +
+ {["days", "hours", "minutes", "seconds"].map((unit, index) => ( + + {index > 0 && :} +
+ + {formatNumber(timeLeft[unit as keyof TimeLeft]).charAt(0)} + + + {formatNumber(timeLeft[unit as keyof TimeLeft]).charAt(1)} + +

{unit}

+
+
+ ))} +
+ ); +}; + +export default Countdown; diff --git a/usehooks.com/src/components/HookDescription.astro b/usehooks.com/src/components/HookDescription.astro new file mode 100644 index 0000000..3ca4182 --- /dev/null +++ b/usehooks.com/src/components/HookDescription.astro @@ -0,0 +1,86 @@ +--- +import Button from "./Button.astro"; +const { name } = Astro.props; +--- + +
+
+

Description:

+ +
+ +
+ + diff --git a/usehooks.com/src/components/Install.astro b/usehooks.com/src/components/Install.astro new file mode 100644 index 0000000..1d97617 --- /dev/null +++ b/usehooks.com/src/components/Install.astro @@ -0,0 +1,82 @@ +--- +const { text } = Astro.props; +const { class: className } = Astro.props; +--- + +
+ {text} + +
+ + + + diff --git a/usehooks.com/src/components/Logo.astro b/usehooks.com/src/components/Logo.astro new file mode 100644 index 0000000..ab953ce --- /dev/null +++ b/usehooks.com/src/components/Logo.astro @@ -0,0 +1,24 @@ +--- +const { class: className } = Astro.props; +--- + + + + \ No newline at end of file diff --git a/usehooks.com/src/components/LogoGithub.astro b/usehooks.com/src/components/LogoGithub.astro new file mode 100644 index 0000000..f21b6a2 --- /dev/null +++ b/usehooks.com/src/components/LogoGithub.astro @@ -0,0 +1,28 @@ +--- +const { class: className } = Astro.props; +--- + + + + \ No newline at end of file diff --git a/usehooks.com/src/components/QueryGGBanner.astro b/usehooks.com/src/components/QueryGGBanner.astro new file mode 100644 index 0000000..451e6c2 --- /dev/null +++ b/usehooks.com/src/components/QueryGGBanner.astro @@ -0,0 +1,138 @@ +--- +import Button from "./Button.astro"; +import CountdownTimer from "./CountdownTimer"; +--- + + + + diff --git a/usehooks.com/src/components/RelatedHook.astro b/usehooks.com/src/components/RelatedHook.astro new file mode 100644 index 0000000..f49b319 --- /dev/null +++ b/usehooks.com/src/components/RelatedHook.astro @@ -0,0 +1,10 @@ +--- +import { getEntry } from "astro:content"; +import HookCard from "./search/HookCard"; + +const { slug } = Astro.props; +const relatedHook = await getEntry("hooks", slug); +const { name, tagline } = relatedHook.data; +--- + + \ No newline at end of file diff --git a/usehooks.com/src/components/StaticCodeContainer.astro b/usehooks.com/src/components/StaticCodeContainer.astro new file mode 100644 index 0000000..612f2c7 --- /dev/null +++ b/usehooks.com/src/components/StaticCodeContainer.astro @@ -0,0 +1,15 @@ +--- +import CodeWrapper from './codepreview/CodeWrapper' +--- +
+

Example:

+
+ + + +
+
+ + \ No newline at end of file diff --git a/usehooks.com/src/components/Svg.astro b/usehooks.com/src/components/Svg.astro new file mode 100644 index 0000000..dcf0e5f --- /dev/null +++ b/usehooks.com/src/components/Svg.astro @@ -0,0 +1,10 @@ +--- +export interface Props { + name: string; +} + +const { name } = Astro.props as Props; +const { default: innerHTML } = await import(`../svg/${name}.svg?raw`); +--- + + \ No newline at end of file diff --git a/usehooks.com/src/components/codepreview/CodePreview.tsx b/usehooks.com/src/components/codepreview/CodePreview.tsx new file mode 100644 index 0000000..70d353d --- /dev/null +++ b/usehooks.com/src/components/codepreview/CodePreview.tsx @@ -0,0 +1,48 @@ +import { + SandpackProvider, + SandpackPreview, + SandpackStack, + SandpackFiles, +} from "@codesandbox/sandpack-react"; +import cx from "classnames"; + +export type PreviewProps = { + previewHeight?: string; + files?: SandpackFiles | undefined; +}; + +export default function CodePreview({ + previewHeight = "250px", + files, +}: PreviewProps) { + const sandpackProviderProps = { + files, + initMode: "user-visible", + autorun: false, + logLevel: 0, + }; + + return ( +
+ + + + + +
+ ); +} diff --git a/usehooks.com/src/components/codepreview/CodeWrapper.tsx b/usehooks.com/src/components/codepreview/CodeWrapper.tsx new file mode 100644 index 0000000..2089420 --- /dev/null +++ b/usehooks.com/src/components/codepreview/CodeWrapper.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { motion } from "framer-motion"; +import { set } from "astro/zod"; + +const springConfig = { + damping: 20, + stiffness: 120, + mass: 0.15, +}; + +export default function CodeWrapper({ + children, +}: { + children: React.ReactNode; +}) { + const [expanded, setExpanded] = useState(false); + const [hideExpand, setHideExpand] = useState(false); + + return ( +
+ +
{ + if (el) { + const { height } = el.getBoundingClientRect(); + if (height < 500) { + setExpanded(true); + setHideExpand(true); + } + } + }} + > + {children} +
+
+ {!hideExpand && ( +
+ +
+ )} +
+ ); +} diff --git a/usehooks.com/src/components/codepreview/utils.ts b/usehooks.com/src/components/codepreview/utils.ts new file mode 100644 index 0000000..c7d0e26 --- /dev/null +++ b/usehooks.com/src/components/codepreview/utils.ts @@ -0,0 +1,40 @@ +import { SandpackFile } from "@codesandbox/sandpack-react"; + +export async function getFiles({ id }: { id: string }) { + const configUrl = `https://codesandbox.io/api/v1/sandboxes/${id}/sandpack`; + const response = await fetch(configUrl); + if (response.ok) { + const data = await response.json(); + // This hardcodes the active file, we should find a better way to do this + return updateFiles(data.files); + } + return {}; +} + +// https://codesandbox.io/s/challenge-ui-test-zurc8s + +function getChallengeConfig(json: string) { + const csb = JSON.parse(json); + + if (csb?.previewConfig) { + return csb.previewConfig; + } + + return { + visibleFiles: [], + activeFile: "/src/App.js", + }; +} + +export function updateFiles(files: { [key: string]: SandpackFile }) { + const previewConfig = getChallengeConfig(files["/package.json"].code); + Object.keys(files).map((key) => { + if (key === previewConfig.activeFile) { + files[key].active = true; + } + if (!previewConfig.visibleFiles.includes(key)) { + files[key].hidden = true; + } + }); + return files; +} diff --git a/usehooks.com/src/components/search/Callout.module.css b/usehooks.com/src/components/search/Callout.module.css new file mode 100644 index 0000000..2cc9093 --- /dev/null +++ b/usehooks.com/src/components/search/Callout.module.css @@ -0,0 +1,43 @@ +.callout :global(a) { + height: 100%; + padding: var(--body-padding); + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + border: var(--border-dark); + border-radius: 0.5rem; + font-size: clamp(0.9rem, 2vw, 1.1rem); + text-align: center; + transition: all 200ms ease-in-out; +} + +.callout :global(a:hover) { + transform: scale(1.03); +} + +.callout :global(img:not(.logo)) { + max-width: 120px; + margin-top: calc(var(--body-padding) * -1.2); + margin-bottom: 0.5rem; +} + +.callout :global(img.d20) { + width: 70px; +} + +.callout :global(img.money) { + width: 110px; +} + +.callout :global(img.spinner) { + width: 80px; +} + +.callout :global(img.hot-sauce) { + width: 80px; +} + +.callout :global(img.logo) { + max-width: 150px; +} diff --git a/usehooks.com/src/components/search/Callout.tsx b/usehooks.com/src/components/search/Callout.tsx new file mode 100644 index 0000000..6b85077 --- /dev/null +++ b/usehooks.com/src/components/search/Callout.tsx @@ -0,0 +1,39 @@ +import styles from "./Callout.module.css"; + +type Props = { + image: string; + imageWidth: string; + imageHeight: string; + imageAlt: string; + pitch: string; +}; + +export default function Callout({ + image, + imageWidth, + imageHeight, + imageAlt, + pitch, +}) { + return ( +
  • + + {imageAlt} + React.gg +

    {pitch}

    +
    +
  • + ); +} diff --git a/usehooks.com/src/components/search/HookCard.module.css b/usehooks.com/src/components/search/HookCard.module.css new file mode 100644 index 0000000..02b4446 --- /dev/null +++ b/usehooks.com/src/components/search/HookCard.module.css @@ -0,0 +1,39 @@ +.hook > :global(a) { + height: 100%; + padding: var(--body-padding); + display: flex; + flex-direction: column; + gap: 0.6rem; + background-color: var(--charcoal); + border-radius: 0.5rem; + transition: transform 200ms ease-in-out; +} + +.hook > :global(a:hover) { + transform: scale(1.03); +} + +.card-title { + color: var(--blue); + text-transform: none; + font-family: var(--font-outfit); + font-size: clamp(1.1rem, 3vw, 1.4rem); + font-weight: 600; +} + +.card-description { + margin-bottom: 0.5rem; + font-size: clamp(0.9rem, 2vw, 1.1rem); +} + +.arrow { + width: 28px; + aspect-ratio: 3 / 2; + margin-top: auto; + align-self: flex-end; + transition: all 200ms ease-in-out; +} + +:global(a:hover) .arrow { + transform: translateX(0.6rem); +} diff --git a/usehooks.com/src/components/search/HookCard.tsx b/usehooks.com/src/components/search/HookCard.tsx new file mode 100644 index 0000000..00bc89a --- /dev/null +++ b/usehooks.com/src/components/search/HookCard.tsx @@ -0,0 +1,32 @@ +import styles from './HookCard.module.css'; + +export default function HookCard({ + name, + tagline, +}: { + name: string; + tagline: string; +}) { + return ( +
  • + +

    {name}

    +

    {tagline}

    + + + +
    +
  • + ); +} diff --git a/usehooks.com/src/components/search/HookSearch.module.css b/usehooks.com/src/components/search/HookSearch.module.css new file mode 100644 index 0000000..bae90e8 --- /dev/null +++ b/usehooks.com/src/components/search/HookSearch.module.css @@ -0,0 +1,37 @@ +.hooks-search { + position: relative; + align-self: flex-start; +} + +.input { + padding: 0.4rem 1rem; + padding-right: 2rem; + background-color: var(--coal); + border: var(--border-light); + border-radius: 1.5rem; + font-size: clamp(0.8rem, 2vw, 1rem); + color: var(--white); + transition: all 150ms ease-in-out; +} + +.input:focus { + outline: none; + border-color: var(--charcoal); + box-shadow: var(--focus-object); +} + +.input + :global(button) { + position: absolute; + right: 0.8rem; + top: 50%; + transform: translateY(-50%); + font-size: 1rem; + transition: all 150ms ease-in-out; +} + +:global(input[type="search"]::-webkit-search-decoration), +:global(input[type="search"]::-webkit-search-cancel-button), +:global(input[type="search"]::-webkit-search-results-button), +:global(input[type="search"]::-webkit-search-results-decoration) { + display: none; +} diff --git a/usehooks.com/src/components/search/HookSearch.tsx b/usehooks.com/src/components/search/HookSearch.tsx new file mode 100644 index 0000000..e927350 --- /dev/null +++ b/usehooks.com/src/components/search/HookSearch.tsx @@ -0,0 +1,27 @@ +import styles from "./HookSearch.module.css"; + +export default function HookSearch({ + handleChange, + handleClear, + value, +}: { + handleClear: () => void; + handleChange: (e: React.ChangeEvent) => void; + value: string; +}) { + return ( +
    + + +
    + ); +} diff --git a/usehooks.com/src/components/search/HookSort.module.css b/usehooks.com/src/components/search/HookSort.module.css new file mode 100644 index 0000000..a4bf450 --- /dev/null +++ b/usehooks.com/src/components/search/HookSort.module.css @@ -0,0 +1,19 @@ +.toggle { + padding: 0.2rem 0.4rem; + border: var(--border-light); + border-radius: 0.3rem; + font-size: clamp(0.7rem, 2vw, 0.9rem); + font-weight: 500; + transition: all 200ms ease-in-out; +} + +.toggle.active { + background-color: var(--yellow); + border-color: var(--yellow); + color: var(--charcoal); + cursor: default; +} + +.toggle:not(.active):hover { + background-color: var(--charcoal); +} diff --git a/usehooks.com/src/components/search/HookSort.tsx b/usehooks.com/src/components/search/HookSort.tsx new file mode 100644 index 0000000..6f0bef4 --- /dev/null +++ b/usehooks.com/src/components/search/HookSort.tsx @@ -0,0 +1,29 @@ +import styles from "./HookSort.module.css"; + +export default function HookSort({ + setSort, + value, +}: { + setSort: (value: "name" | "popular") => void; + value: "name" | "popular"; +}) { + return ( +
    + Sort: + + +
    + ); +} diff --git a/usehooks.com/src/components/search/HooksList.module.css b/usehooks.com/src/components/search/HooksList.module.css new file mode 100644 index 0000000..256c1cb --- /dev/null +++ b/usehooks.com/src/components/search/HooksList.module.css @@ -0,0 +1,17 @@ +.hooks-grid { + max-width: 980px; + margin: 2rem auto; +} + +.hooks-controls { + padding: 1rem var(--body-padding); + display: flex; + justify-content: flex-end; +} + +.hooks-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + align-items: stretch; + gap: 1.6rem; +} diff --git a/usehooks.com/src/components/search/HooksList.tsx b/usehooks.com/src/components/search/HooksList.tsx new file mode 100644 index 0000000..f06c488 --- /dev/null +++ b/usehooks.com/src/components/search/HooksList.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import Callout from "./Callout"; +import HookCard from "./HookCard"; +import HookSort from "./HookSort"; +import styles from "./HooksList.module.css"; + +function insertAtIntervals(arr, items) { + let newArr = [...arr]; // create a copy of the array + let step = Math.ceil(newArr.length / items.length); + + // Insert the first item at the beginning of the array + newArr.unshift(items[0]); + + for (let i = 1; i < items.length; i++) { + let position = i * step + 1; // +1 to account for the first item + newArr.splice(position, 0, items[i]); + } + + return newArr; +} + +function sortAlphabetical(a, b) { + if (a.data.name < b.data.name) { + return -1; + } + if (a.data.name > b.data.name) { + return 1; + } + return 0; +} + +function sortByPopularity(a, b) { + if (a.data.rank < b.data.rank) { + return -1; + } + if (a.data.rank > b.data.rank) { + return 1; + } + return 0; +} + +const sortMap = { + name: sortAlphabetical, + popular: sortByPopularity, +}; + +export default function HooksList({ hooks }) { + const [sort, setSort] = useState<"name" | "popular">("popular"); + + const list = hooks.sort(sortMap[sort]); + const listWithCallouts = insertAtIntervals(list, [ + { + id: "Callout 1", + image: "d20", + imageWidth: "222", + imageHeight: "206", + imageAlt: "20-sided die", + pitch: + "It’s dangerous to go alone! Master React by learning how to build useHooks yourself.", + }, + { + id: "Callout 2", + image: "spinner", + imageWidth: "284", + imageHeight: "180", + imageAlt: "board game spinner and all options are React", + pitch: + "There’s no better way to learn useHooks than by building it yourself.", + }, + { + id: "Callout 3", + image: "money", + imageWidth: "210", + imageHeight: "210", + imageAlt: "$100 Monopoly-style money", + pitch: + "Please give us your money.", + }, + { + id: "Callout 4", + image: "hot-sauce", + imageWidth: "206", + imageHeight: "224", + imageAlt: "travel-style postcard from React that says “Enjoy the views!", + pitch: + "The all new interactive way to master modern React (for fun and profit).", + } + ]); + + return ( +
    +
    + +
    +
      + {listWithCallouts.map( + ({ data, id, image, imageWidth, imageHeight, imageAlt, pitch }) => { + if (!data) { + return ( + + ); + } + return ( + + ); + } + )} +
    +
    + ); +} diff --git a/usehooks.com/src/content/config.ts b/usehooks.com/src/content/config.ts new file mode 100644 index 0000000..47c3ae5 --- /dev/null +++ b/usehooks.com/src/content/config.ts @@ -0,0 +1,19 @@ +import { defineCollection, z, reference } from 'astro:content'; + +export const collections = { + hooks: defineCollection({ + schema: z.object({ + experimental: z.boolean().optional(), + draft: z.boolean().default(false), + sandboxId: z.string().optional(), + previewHeight: z.string().optional(), + name: z.string(), + tagline: z.string(), + ogImage: z.string().optional(), + rank: z.number(), + relatedHooks: z.array( + reference('hooks').optional() + ).optional(), + }), + }), +}; \ No newline at end of file diff --git a/usehooks.com/src/content/hooks/useBattery.mdx b/usehooks.com/src/content/hooks/useBattery.mdx new file mode 100644 index 0000000..997d14a --- /dev/null +++ b/usehooks.com/src/content/hooks/useBattery.mdx @@ -0,0 +1,75 @@ +--- +name: useBattery +rank: 32 +tagline: Track the battery status of a user’s device with useBattery. +sandboxId: usebattery-o8js1p +previewHeight: 320px +relatedHooks: + - usenetworkstate + - usepreferredlanguage +--- + +import CodePreview from "../../components/CodePreview.astro"; +import HookDescription from "../../components/HookDescription.astro"; +import StaticCodeContainer from "../../components/StaticCodeContainer.astro"; + + + The useBattery hook is useful for accessing and monitoring the battery status + of the user’s device in a React application. By using this hook, you can + easily retrieve information such as the battery level, charging status, and + estimated charging and discharging times. It provides a state object that + includes properties like supported, loading, level, charging, chargingTime, + and dischargingTime. + + +
    + ### Return Values + The hook returns an object containing the following properties: + +
    + | Name | Type | Description | + | ---------------- | ------- | ----------- | + | supported | boolean | Indicates whether the Battery Status API is supported in the user’s browser. | + | loading | boolean | Indicates if the battery information is still loading. | + | level | number | Represents the level of the system’s battery. 0.0 means that the system’s battery is completely discharged, and 1.0 means the battery is completely charged. | + | charging | boolean | Represents whether the system’s battery is charging. `true` means the battery is charging, `false` means it’s not. | + | chargingTime | number | Represents the time remaining in seconds until the system’s battery is fully charged. | + | dischargingTime | number | Represents the time remaining in seconds until the system’s battery is completely discharged and the system is about to be suspended. | +
    +
    + + + + + +```jsx +import { useBattery } from "@uidotdev/usehooks"; +import Battery from "./Battery"; + +export default function App() { + const { loading, level, charging, chargingTime, dischargingTime } = + useBattery(); + return ( + <> +
    +

    useBattery

    + {!loading ? ( + + ) : ( +

    Loading...

    + )} +
    + + ); +} +``` + +
    diff --git a/usehooks.com/src/content/hooks/useClickAway.mdx b/usehooks.com/src/content/hooks/useClickAway.mdx new file mode 100644 index 0000000..fd28da4 --- /dev/null +++ b/usehooks.com/src/content/hooks/useClickAway.mdx @@ -0,0 +1,93 @@ +--- +name: useClickAway +rank: 47 +tagline: Detect clicks outside of specific component with useClickAway. +sandboxId: useclickaway-p4hl4m +previewHeight: 250px +relatedHooks: + - uselongpress + - usehover +--- + +import CodePreview from "../../components/CodePreview.astro"; +import HookDescription from "../../components/HookDescription.astro"; +import StaticCodeContainer from "../../components/StaticCodeContainer.astro"; + + + The useClickAway hook is a useful for detecting clicks outside a specific + component. It allows you to pass a callback function that will be triggered + whenever a click occurs outside the component’s area. This hook is + particularly helpful when implementing dropdown menus, modals, or any other UI + elements that need to be closed when the user clicks outside of them. By + attaching event listeners to the document, the hook checks if the click target + is within the component’s reference, and if not, it invokes the provided + callback function. + + +
    + ### Parameters + +
    + | Name | Type | Description | + | ---- | -------- | ----------- | + | callback | function | The callback function that is provided as an argument to `useClickAway`. This function is invoked whenever a click event is detected outside of the referenced element. The event object from the click is passed to this callback function. | +
    + + ### Return Values + +
    + | Name | Type | Description | + | ---- | ------------ | ----------- | + | ref | React ref | This is a ref object returned by the hook. It should be attached to a React element to monitor click events. The ref provides a way to access the properties of the element it is attached to. | +
    +
    + + + + + + +```jsx +import * as React from "react"; +import { useClickAway } from "@uidotdev/usehooks"; +import { closeIcon } from "./icons"; + +export default function App() { + const [isOpen, setIsOpen] = React.useState(false); + const ref = useClickAway(() => { + setIsOpen(false); + }); + + const handleOpenModal = () => { + if (isOpen === false) { + setIsOpen(true); + } + }; + + return ( + <> +
    +

    useClickAway

    + +
    + {isOpen && ( + + +

    Modal

    +

    + Click outside the modal to close (or use the button) whatever you + prefer. +

    +
    + )} + + ); +} +``` + +
    diff --git a/usehooks.com/src/content/hooks/useContinuousRetry.mdx b/usehooks.com/src/content/hooks/useContinuousRetry.mdx new file mode 100644 index 0000000..9d088d6 --- /dev/null +++ b/usehooks.com/src/content/hooks/useContinuousRetry.mdx @@ -0,0 +1,76 @@ +--- +name: useContinuousRetry +experimental: true +rank: 12 +tagline: Automates retries of a callback function until it succeeds with useContinuousRetry +sandboxId: usecontinuousretry-v0uf1n +previewHeight: 380px +relatedHooks: + - usesessionstorage +--- + +import CodePreview from "../../components/CodePreview.astro"; +import HookDescription from "../../components/HookDescription.astro"; +import StaticCodeContainer from "../../components/StaticCodeContainer.astro"; + + + The useContinuousRetry hook allows you to repeatedly call a specified callback + function at a defined interval until the callback returns a truthy value, + indicating a successful resolution. This hook is particularly handy when + dealing with asynchronous operations or API calls that may fail temporarily + and need to be retried automatically. It encapsulates the logic of retrying + and provides a clean interface to handle retry-related states, such as whether + the retry process has resolved or not. + + +
    + ### Parameters + +
    + | Name | Type | Description | + | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------| + | callback | function | The callback function to be executed repeatedly until it returns a truthy value. | + | interval | number | (Optional) The interval in milliseconds at which the callback function is executed. Default value is 100 milliseconds. | + | options | object | (Optional) An object containing a `maxRetries` property which tells `useContinuousRetry` the maximum amount of retry attempts it should make before stopping | +
    + + ### Return Value + +
    + | Type | Description | + | ------- | ------------------------------------------------------------------------------------------ | + | boolean | `true` if the callback function has resolved (returned a truthy value), `false` otherwise. | +
    +
    + + + + + +```jsx +import * as React from "react"; +import { useContinuousRetry } from "@uidotdev/usehooks"; + +export default function App() { + const [count, setCount] = React.useState(0); + const hasResolved = useContinuousRetry(() => { + console.log("retrying"); + return count > 10; + }, 1000); + + return ( +
    +

    useContinuousRetry

    + +
    {JSON.stringify({ hasResolved, count }, null, 2)}
    +
    + ); +} +``` + +
    diff --git a/usehooks.com/src/content/hooks/useCopyToClipboard.mdx b/usehooks.com/src/content/hooks/useCopyToClipboard.mdx new file mode 100644 index 0000000..555fb0c --- /dev/null +++ b/usehooks.com/src/content/hooks/useCopyToClipboard.mdx @@ -0,0 +1,87 @@ +--- +name: useCopyToClipboard +rank: 31 +tagline: Copy text to the clipboard using useCopyToClipboard. +sandboxId: usecopytoclipboard-y22r6w +previewHeight: 300px +relatedHooks: + - usemap + - usequeue +--- + +import CodePreview from "../../components/CodePreview.astro"; +import HookDescription from "../../components/HookDescription.astro"; +import StaticCodeContainer from "../../components/StaticCodeContainer.astro"; + + + The useCopyToClipboard hook is useful because it abstracts the complexity of + copying text to the clipboard in a cross-browser compatible manner. It + utilizes the modern navigator.clipboard.writeText method if available, which + provides a more efficient and secure way to copy text. In case the writeText + method is not supported by the browser, it falls back to a traditional method + using the document.execCommand("copy") approach. + + +
    + ### Return Value + + The `useCopyToClipboard` hook returns an array with the following elements: + +
    + | Index | Type | Description | + | ----- | -------- | ------------------------------------------------------ | + | 0 | string | The value that was last copied to the clipboard. | + | 1 | function | A function to copy a specified value to the clipboard. | +
    +
    + + + + + +```jsx +import * as React from "react"; +import { useCopyToClipboard } from "@uidotdev/usehooks"; +import { copyIcon, checkIcon } from "./icons"; + +const randomHash = crypto.randomUUID(); + +export default function App() { + const [copiedText, copyToClipboard] = useCopyToClipboard(); + const hasCopiedText = Boolean(copiedText); + return ( +
    +

    useCopyToClipboard

    +
    + +
    +          {randomHash}
    +          
    +        
    +
    + {hasCopiedText && ( + +

    + Copied{" "} + + 🎉 + +

    +