diff --git a/biome.json b/biome.json index 1d1498b..8712c6c 100644 --- a/biome.json +++ b/biome.json @@ -1,12 +1,35 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - } + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noAssignInExpressions": "off", + "noExplicitAny": "off" + }, + "correctness": { + "useExhaustiveDependencies": "off" + }, + "complexity": { + "noBannedTypes": "off" + } + } + } } diff --git a/package.json b/package.json index 4375261..704f44d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "", "main": "index.js", + "type": "module", "scripts": { "lint": "pnpm recursive run lint", "lint:fix": "pnpm recursive run lint:fix" diff --git a/packages/core/package.json b/packages/core/package.json index a7a5e06..69ae7e2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "Component library collection for @rhino-ui", "main": "index.js", + "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "lint": "biome format && biome lint", diff --git a/packages/hooks/package.json b/packages/hooks/package.json index fd76561..e2d79b4 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -2,9 +2,10 @@ "name": "@rhino-ui/hooks", "version": "1.0.0", "description": "React hooks collection for @rhino-ui", - "main": "index.js", + "main": "./dist/index.js", + "type": "module", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "build": "tsc", "lint": "biome format && biome lint", "lint:fix": "biome format --write && biome lint --fix" }, @@ -15,9 +16,14 @@ "url": "https://rhinolabs.agency" }, "license": "MIT", - "dependencies": {}, + "dependencies": { + "react": "catalog:" + }, "devDependencies": { - "typescript": "catalog:", - "@biomejs/biome": "catalog:" + "@biomejs/biome": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "catalog:" } } diff --git a/packages/hooks/src/App.tsx b/packages/hooks/src/App.tsx new file mode 100644 index 0000000..135846b --- /dev/null +++ b/packages/hooks/src/App.tsx @@ -0,0 +1,5 @@ +const App = () => { + return

Test your hook here

; +}; + +export default App; diff --git a/packages/hooks/src/hooks/useArray.ts b/packages/hooks/src/hooks/useArray.ts new file mode 100644 index 0000000..02ef8d4 --- /dev/null +++ b/packages/hooks/src/hooks/useArray.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; + +export default function useArray(initialArray: T[]) { + const [array, setArray] = useState(initialArray); + + const push = (element: T) => { + setArray((prev) => [...prev, element]); + }; + + const filter = (callback: (element: T) => boolean) => { + setArray((prev) => prev.filter(callback)); + }; + + const update = (index: number, newElement: T) => { + setArray((prev) => [ + ...prev.slice(0, index), + newElement, + ...prev.slice(index + 1, prev.length), + ]); + }; + + const remove = (index: number) => { + setArray((prev) => [ + ...prev.slice(0, index), + ...prev.slice(index + 1, prev.length), + ]); + }; + + const clear = () => { + setArray([]); + }; + + return { + array, + set: setArray, + push, + filter, + update, + remove, + clear, + }; +} diff --git a/packages/hooks/src/hooks/useAsync.ts b/packages/hooks/src/hooks/useAsync.ts new file mode 100644 index 0000000..c152f0a --- /dev/null +++ b/packages/hooks/src/hooks/useAsync.ts @@ -0,0 +1,42 @@ +import { useState, useCallback } from "react"; + +interface UseAsyncState { + data: T | null; + isLoading: boolean; + error: Error | null; + isSuccess: boolean; +} + +export const useAsync = () => { + const [state, setState] = useState>({ + data: null, + isLoading: false, + error: null, + isSuccess: false, + }); + + const execute = useCallback(async (asyncFunction: () => Promise) => { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const result = await asyncFunction(); + setState({ + data: result, + isLoading: false, + error: null, + isSuccess: true, + }); + return result; + } catch (error) { + setState({ + data: null, + isLoading: false, + error: error as Error, + isSuccess: false, + }); + throw error; + } + }, []); + + return { execute, ...state }; +}; diff --git a/packages/hooks/src/hooks/useAudio.ts b/packages/hooks/src/hooks/useAudio.ts new file mode 100644 index 0000000..6704a11 --- /dev/null +++ b/packages/hooks/src/hooks/useAudio.ts @@ -0,0 +1,155 @@ +import { useEffect, useState, type RefObject } from "react"; + +export const useAudio = (ref: RefObject) => { + const audio = ref.current; + + const [audioState, setAudioState] = useState({ + isPaused: audio ? audio?.paused : true, + isMuted: audio ? audio?.muted : false, + currentVolume: audio ? audio?.volume : 100, + currentTime: audio ? audio?.currentTime : 0, + }); + + const play = () => { + audio?.play(); + setAudioState((prev) => { + return { + ...prev, + isPaused: false, + isMuted: audio ? audio.muted : prev.isMuted, + }; + }); + }; + + const pause = () => { + audio?.pause(); + setAudioState((prev) => { + return { + ...prev, + isPaused: true, + }; + }); + }; + + const handlePlayPauseControl = (e: Event) => { + setAudioState((prev) => { + return { + ...prev, + isPaused: (e.target as HTMLAudioElement).paused, + }; + }); + }; + + const togglePause = () => (audio?.paused ? play() : pause()); + + const handleVolume = (delta: number) => { + const deltaDecimal = delta / 100; + + if (audio) { + let newVolume = audio?.volume + deltaDecimal; + + if (newVolume >= 1) { + newVolume = 1; + } else if (newVolume <= 0) { + newVolume = 0; + } + + audio.volume = newVolume; + setAudioState((prev) => { + return { + ...prev, + currentVolume: newVolume * 100, + }; + }); + } + }; + + const handleVolumeControl = (e: Event) => { + if (e.target && audio) { + const newVolume = (e.target as HTMLAudioElement).volume * 100; + + handleMute(audio.muted); + setAudioState((prev) => ({ + ...prev, + currentVolume: newVolume, + })); + } + }; + + const handleMute = (mute: boolean) => { + if (audio) { + audio.muted = mute; + setAudioState((prev) => { + return { + ...prev, + isMuted: mute, + }; + }); + } + }; + + const handleTime = (delta = 5) => { + if (audio) { + let newTime = audio.currentTime + delta; + + if (newTime >= audio.duration) { + newTime = audio.duration; + } else if (newTime <= 0) { + newTime = 0; + } + + audio.currentTime = newTime; + setAudioState((prev) => { + return { + ...prev, + currentTime: newTime, + }; + }); + } + }; + + const handleTimeControl = (e: Event) => { + setAudioState((prev) => { + return { + ...prev, + currentTime: (e.target as HTMLAudioElement).currentTime, + }; + }); + }; + + useEffect(() => { + return () => { + pause(); + }; + }, []); + + useEffect(() => { + if (audio) { + audio.addEventListener("volumechange", handleVolumeControl); + audio.addEventListener("play", handlePlayPauseControl); + audio.addEventListener("pause", handlePlayPauseControl); + audio.addEventListener("timeupdate", handleTimeControl); + + return () => { + audio.removeEventListener("volumechange", handleVolumeControl); + audio.removeEventListener("play", handlePlayPauseControl); + audio.removeEventListener("pause", handlePlayPauseControl); + audio.removeEventListener("timeupdate", handleTimeControl); + }; + } + }, [audio]); + + return { + ...audioState, + play, + pause, + togglePause, + increaseVolume: (increase = 5) => handleVolume(increase), + decreaseVolume: (decrease = 5) => handleVolume(decrease * -1), + mute: () => handleMute(true), + unmute: () => handleMute(false), + toggleMute: () => handleMute(!audio?.muted), + forward: (increase = 5) => handleTime(increase), + back: (decrease = 5) => handleTime(decrease * -1), + }; +}; diff --git a/packages/hooks/src/hooks/useBattery.ts b/packages/hooks/src/hooks/useBattery.ts new file mode 100644 index 0000000..273a64a --- /dev/null +++ b/packages/hooks/src/hooks/useBattery.ts @@ -0,0 +1,91 @@ +import { useState, useEffect } from "react"; + +interface BatteryManager { + level: number; + charging: boolean; + chargingTime: number; + dischargingTime: number; + addEventListener( + type: string, + listener: EventListener | EventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListener | EventListenerObject | null, + options?: boolean | EventListenerOptions, + ): void; +} + +interface BatteryState { + supported: boolean; + loading: boolean; + level: number | null; + charging: boolean | null; + chargingTime: number | null; + dischargingTime: number | null; +} + +interface NavigatorWithBattery extends Navigator { + getBattery: () => Promise; +} + +export const useBattery = () => { + const [batteryState, setBatteryState] = useState({ + supported: true, + loading: true, + level: null, + charging: null, + chargingTime: null, + dischargingTime: null, + }); + + useEffect(() => { + const _navigator = navigator as NavigatorWithBattery; + let battery: BatteryManager; + + const handleBatteryChange = () => { + setBatteryState({ + supported: true, + loading: false, + level: battery.level, + charging: battery.charging, + chargingTime: battery.chargingTime, + dischargingTime: battery.dischargingTime, + }); + }; + + if (!_navigator.getBattery) { + setBatteryState((batteryState) => ({ + ...batteryState, + supported: false, + loading: false, + })); + return; + } + + _navigator.getBattery().then((_battery) => { + battery = _battery; + handleBatteryChange(); + + _battery.addEventListener("levelchange", handleBatteryChange); + _battery.addEventListener("chargingchange", handleBatteryChange); + _battery.addEventListener("chargingtimechange", handleBatteryChange); + _battery.addEventListener("dischargingtimechange", handleBatteryChange); + }); + + return () => { + if (battery) { + battery.removeEventListener("levelchange", handleBatteryChange); + battery.removeEventListener("chargingchange", handleBatteryChange); + battery.removeEventListener("chargingtimechange", handleBatteryChange); + battery.removeEventListener( + "dischargingtimechange", + handleBatteryChange, + ); + } + }; + }, []); + + return batteryState; +}; diff --git a/packages/hooks/src/hooks/useClipboard.ts b/packages/hooks/src/hooks/useClipboard.ts new file mode 100644 index 0000000..6dfe0b6 --- /dev/null +++ b/packages/hooks/src/hooks/useClipboard.ts @@ -0,0 +1,31 @@ +import { useState } from "react"; + +export const useClipboard = () => { + const [copiedText, setCopiedText] = useState(""); + + const copyToClipboard = (value: string) => { + return new Promise((resolve, reject) => { + try { + if (navigator?.clipboard?.writeText) { + navigator.clipboard + .writeText(value) + .then(() => { + setCopiedText(value); + resolve(value); + }) + .catch((e) => { + setCopiedText(null); + reject(e); + }); + } else { + setCopiedText(null); + throw new Error("Clipboard not supported"); + } + } catch (e) { + reject(e); + } + }); + }; + + return { copiedText, copyToClipboard }; +}; diff --git a/packages/hooks/src/hooks/useCountdown.ts b/packages/hooks/src/hooks/useCountdown.ts new file mode 100644 index 0000000..c8628dd --- /dev/null +++ b/packages/hooks/src/hooks/useCountdown.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from "react"; + +interface Counter { + current: string; + isPaused: boolean; + isOver: boolean; + pause: () => void; + play: () => void; + reset: () => void; + togglePause: () => void; +} + +export const useCountdown = (min: number, max: number): Counter => { + const [count, setCount] = useState(max); + const [paused, setPaused] = useState(false); + const [isOver, setIsOver] = useState(false); + + useEffect(() => { + if (paused) { + return; + } + + const interval = setInterval(() => { + setCount((prev) => prev - 1); + }, 1000); + + if (count <= min) { + setIsOver(true); + clearInterval(interval); + return; + } + + return () => clearInterval(interval); + }, [count, min, max, paused]); + + return { + current: count.toString(), + isPaused: paused, + isOver, + pause: () => setPaused(true), + play: () => setPaused(false), + reset: () => { + setIsOver(false); + setCount(max); + }, + togglePause: () => { + setPaused(!paused); + }, + }; +}; diff --git a/packages/hooks/src/hooks/useCountup.ts b/packages/hooks/src/hooks/useCountup.ts new file mode 100644 index 0000000..bb8feb7 --- /dev/null +++ b/packages/hooks/src/hooks/useCountup.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from "react"; + +interface Counter { + current: string; + isPaused: boolean; + isOver: boolean; + pause: () => void; + play: () => void; + reset: () => void; + togglePause: () => void; +} + +export const useCountup = (min: number, max: number): Counter => { + const [count, setCount] = useState(min); + const [paused, setPaused] = useState(false); + const [isOver, setIsOver] = useState(false); + + useEffect(() => { + if (paused) { + return; + } + + const interval = setInterval(() => { + setCount((prev) => prev + 1); + }, 1000); + + if (count >= max) { + setIsOver(true); + clearInterval(interval); + return; + } + + return () => clearInterval(interval); + }, [count, min, max, paused]); + + return { + current: count.toString(), + isPaused: paused, + isOver, + pause: () => setPaused(true), + play: () => setPaused(false), + reset: () => { + setIsOver(false); + setCount(min); + }, + togglePause: () => { + setPaused(!paused); + }, + }; +}; diff --git a/packages/hooks/src/hooks/useDebounce.ts b/packages/hooks/src/hooks/useDebounce.ts new file mode 100644 index 0000000..bb82218 --- /dev/null +++ b/packages/hooks/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export const useDebounce = (value: T, delay: number) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handleTimeout = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handleTimeout); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/packages/hooks/src/hooks/useDownload.ts b/packages/hooks/src/hooks/useDownload.ts new file mode 100644 index 0000000..ea6308b --- /dev/null +++ b/packages/hooks/src/hooks/useDownload.ts @@ -0,0 +1,97 @@ +import { useState } from "react"; + +export const useDownload = () => { + const [error, setError] = useState(null); + const [isDownloading, setIsDownloading] = useState(false); + const [progress, setProgress] = useState(null); + + const handleResponse = async (response: Response): Promise => { + if (!response.ok) { + throw new Error("Could not download file"); + } + + const contentLength = response.headers.get("content-length"); + const reader = response.clone().body?.getReader(); + + if (!contentLength || !reader) { + const blob = await response.blob(); + + return createBlobURL(blob); + } + + const stream = await getStream(contentLength, reader); + const newResponse = new Response(stream); + const blob = await newResponse.blob(); + + return createBlobURL(blob); + }; + + const getStream = async ( + contentLength: string, + reader: ReadableStreamDefaultReader, + ): Promise> => { + let loaded = 0; + const total = Number.parseInt(contentLength, 10); + + return new ReadableStream({ + async start(controller) { + try { + for (;;) { + const { done, value } = await reader.read(); + + if (done) break; + + loaded += value.byteLength; + const percentage = Math.trunc((loaded / total) * 100); + setProgress(percentage); + controller.enqueue(value); + } + } catch (error) { + controller.error(error); + throw error; + } finally { + controller.close(); + } + }, + }); + }; + + const createBlobURL = (blob: Blob): string => { + return window.URL.createObjectURL(blob); + }; + + const handleDownload = (fileName: string, url: string) => { + const link = document.createElement("a"); + + link.href = url; + link.setAttribute("download", fileName); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }; + + const downloadFile = async (fileName: string, fileUrl: string) => { + setIsDownloading(true); + setError(null); + setProgress(null); + + try { + const response = await fetch(fileUrl); + const url = await handleResponse(response); + + handleDownload(fileName, url); + } catch (error) { + setError(error); + } finally { + setIsDownloading(false); + } + }; + + return { + error, + isDownloading, + progress, + downloadFile, + }; +}; diff --git a/packages/hooks/src/hooks/useEventListener.ts b/packages/hooks/src/hooks/useEventListener.ts new file mode 100644 index 0000000..9014922 --- /dev/null +++ b/packages/hooks/src/hooks/useEventListener.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef } from "react"; + +export default function useEventListener( + eventName: string, + callback: EventListener, + element: + | HTMLElement + | (Window & typeof globalThis) + | Document + | null = window, +) { + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + if (!element?.addEventListener) { + return; + } + + const eventListener = (event: Event) => callbackRef.current(event); + + element.addEventListener(eventName, eventListener); + + return () => { + element.removeEventListener(eventName, eventListener); + }; + }, [eventName, element]); +} diff --git a/packages/hooks/src/hooks/useFavicon.ts b/packages/hooks/src/hooks/useFavicon.ts new file mode 100644 index 0000000..4c678ca --- /dev/null +++ b/packages/hooks/src/hooks/useFavicon.ts @@ -0,0 +1,22 @@ +import { useState } from "react"; + +export const useFavicon = () => { + const [faviconUrl, setFaviconUrl] = useState( + (document.querySelector(`link[rel~="icon"]`) as HTMLLinkElement)?.href, + ); + + const changeFavicon = (newFavicon: string) => { + let link = document.querySelector(`link[rel~="icon"]`) as HTMLLinkElement; + + if (!link) { + link = document.createElement("link"); + link.rel = "icon"; + document.head.appendChild(link); + } + + link.href = newFavicon; + setFaviconUrl(newFavicon); + }; + + return { faviconUrl, changeFavicon }; +}; diff --git a/packages/hooks/src/hooks/useFetch.ts b/packages/hooks/src/hooks/useFetch.ts new file mode 100644 index 0000000..4d45cf8 --- /dev/null +++ b/packages/hooks/src/hooks/useFetch.ts @@ -0,0 +1,49 @@ +import { useState, useEffect } from "react"; + +// Define the interface for the data returned by the API. +type Data = {}; + +// Define the interface for the error returned by the API. +type Error = {}; + +export const useFetch = (url: string, reqOpt?: RequestInit) => { + const [data, setData] = useState(); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + + const fetchData = async () => { + setIsLoading(true); + + try { + const res = await fetch(url, reqOpt && reqOpt); + const data = await res.json(); + + if (res.status === 200) { + setIsSuccess(true); + setData(data); + setError(undefined); + } else { + setIsSuccess(false); + setError(data); + setData(undefined); + } + } catch (e) { + setIsSuccess(false); + setData(undefined); + if (e instanceof Error) { + setError(e); + } + } + + setIsLoading(false); + }; + + useEffect(() => { + fetchData(); + }, []); + + const refetch = () => fetchData(); + + return { data, error, isLoading, isError: !isSuccess, isSuccess, refetch }; +}; diff --git a/packages/hooks/src/hooks/useFirstRender.ts b/packages/hooks/src/hooks/useFirstRender.ts new file mode 100644 index 0000000..d579aad --- /dev/null +++ b/packages/hooks/src/hooks/useFirstRender.ts @@ -0,0 +1,11 @@ +import { useRef, useEffect } from "react"; + +export const useFirstRender = () => { + const firstRender = useRef(true); + + useEffect(() => { + firstRender.current = false; + }, []); + + return firstRender.current; +}; diff --git a/packages/hooks/src/hooks/useFirstVisit.ts b/packages/hooks/src/hooks/useFirstVisit.ts new file mode 100644 index 0000000..408712e --- /dev/null +++ b/packages/hooks/src/hooks/useFirstVisit.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from "react"; + +export const useFirstVisit = (): boolean => { + const [isFirstVisit, setIsFirstVisit] = useState(false); + + useEffect(() => { + const firstVisit = localStorage.getItem("firstVisit"); + + if (firstVisit === null) { + localStorage.setItem("firstVisit", "false"); + setIsFirstVisit(true); + } + }, []); + + return isFirstVisit; +}; diff --git a/packages/hooks/src/hooks/useGeolocation.ts b/packages/hooks/src/hooks/useGeolocation.ts new file mode 100644 index 0000000..bb44149 --- /dev/null +++ b/packages/hooks/src/hooks/useGeolocation.ts @@ -0,0 +1,36 @@ +import { useState } from "react"; + +interface Payload { + lat: number; + lng: number; +} + +export function useGeolocation(defaultPosition: Payload | null = null) { + const [isLoading, setIsLoading] = useState(false); + const [position, setPosition] = useState(defaultPosition); + const [error, setError] = useState(null); + + function getPosition(): void { + if (!navigator.geolocation) { + setError("Your browser does not support geolocation"); + return; + } + + setIsLoading(true); + navigator.geolocation.getCurrentPosition( + (pos) => { + setPosition({ + lat: pos.coords.latitude, + lng: pos.coords.longitude, + }); + setIsLoading(false); + }, + (error) => { + setError(error.message); + setIsLoading(false); + }, + ); + } + + return { isLoading, position, error, getPosition }; +} diff --git a/packages/hooks/src/hooks/useHover.ts b/packages/hooks/src/hooks/useHover.ts new file mode 100644 index 0000000..45d242c --- /dev/null +++ b/packages/hooks/src/hooks/useHover.ts @@ -0,0 +1,24 @@ +import { useState, type RefObject, useEffect } from "react"; + +export const useHover = (ref: RefObject) => { + const [isHovered, setIsHovered] = useState(false); + + const handleMouseEnter = () => setIsHovered(true); + const handleMouseLeave = () => setIsHovered(false); + + useEffect(() => { + const node = ref.current; + + if (node) { + node.addEventListener("mouseenter", handleMouseEnter); + node.addEventListener("mouseleave", handleMouseLeave); + + return () => { + node.removeEventListener("mouseenter", handleMouseEnter); + node.removeEventListener("mouseleave", handleMouseLeave); + }; + } + }, [ref]); + + return isHovered; +}; diff --git a/packages/hooks/src/hooks/useInput.ts b/packages/hooks/src/hooks/useInput.ts new file mode 100644 index 0000000..401e04a --- /dev/null +++ b/packages/hooks/src/hooks/useInput.ts @@ -0,0 +1,11 @@ +import { useState } from "react"; + +export const useInput = (initialValue: T) => { + const [inputValue, setInputValue] = useState(initialValue); + + const onInputChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value as unknown as T); + }; + + return { inputValue, onInputChange }; +}; diff --git a/packages/hooks/src/hooks/useIsTouchDevice.ts b/packages/hooks/src/hooks/useIsTouchDevice.ts new file mode 100644 index 0000000..0408882 --- /dev/null +++ b/packages/hooks/src/hooks/useIsTouchDevice.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +export function useIsTouchDevice() { + const [isTouchDevice, setIsTouchDevice] = useState(false); + + useEffect(() => { + function onResize() { + setIsTouchDevice( + "ontouchstart" in window || + navigator.maxTouchPoints > 0 || + navigator.maxTouchPoints > 0, + ); + } + + window.addEventListener("resize", onResize); + onResize(); + + return () => { + window.removeEventListener("resize", onResize); + }; + }, []); + + return isTouchDevice; +} diff --git a/packages/hooks/src/hooks/useKeyPress.ts b/packages/hooks/src/hooks/useKeyPress.ts new file mode 100644 index 0000000..0aa0900 --- /dev/null +++ b/packages/hooks/src/hooks/useKeyPress.ts @@ -0,0 +1,51 @@ +import { useState, useEffect } from "react"; + +interface KeyConfig { + key: string; + ctrl?: boolean; + alt?: boolean; + shift?: boolean; +} + +export const useKeyPress = (config: KeyConfig) => { + const [keyPressed, setKeyPressed] = useState(false); + const { key: targetKey, ctrl, alt, shift } = config; + + const handleKeyDown = (e: KeyboardEvent) => { + const { key, ctrlKey, altKey, shiftKey } = e; + + if ( + (!ctrl && !alt && !shift && key === targetKey) || + (ctrl && key === targetKey && ctrlKey === ctrl) || + (alt && key === targetKey && altKey === alt) || + (shift && key === targetKey && shiftKey === shift) + ) { + setKeyPressed(true); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + const { key, ctrlKey, altKey, shiftKey } = e; + + if ( + (!ctrl && !alt && !shift && key === targetKey) || + (ctrl && key === targetKey && ctrlKey === ctrl) || + (alt && key === targetKey && altKey === alt) || + (shift && key === targetKey && shiftKey === shift) + ) { + setKeyPressed(false); + } + }; + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, []); + + return keyPressed; +}; diff --git a/packages/hooks/src/hooks/useLang.ts b/packages/hooks/src/hooks/useLang.ts new file mode 100644 index 0000000..a5fc68b --- /dev/null +++ b/packages/hooks/src/hooks/useLang.ts @@ -0,0 +1,11 @@ +import { useSyncExternalStore } from "react"; + +const langSubscribe = (cb: () => void) => { + window.addEventListener("languagechange", cb); + return () => window.removeEventListener("languagechange", cb); +}; + +const getLang = () => navigator.language; + +export const useLang = (): string => + useSyncExternalStore(langSubscribe, getLang); diff --git a/packages/hooks/src/hooks/useLocalStorage.ts b/packages/hooks/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..2d36524 --- /dev/null +++ b/packages/hooks/src/hooks/useLocalStorage.ts @@ -0,0 +1,69 @@ +import { useEffect, useSyncExternalStore, useCallback } from "react"; + +const isFunction = ( + value: T | ((prevState: T) => T), +): value is (prevState: T) => T => typeof value === "function"; + +const dispatchStorageEvent = (key: string, newValue: string | null) => + window.dispatchEvent(new StorageEvent("storage", { key, newValue })); + +const getLocalStorageItem = (key: string) => window.localStorage.getItem(key); + +const setLocalStorageItem = (key: string, value: T) => { + const stringifiedValue = JSON.stringify(value); + window.localStorage.setItem(key, stringifiedValue); + dispatchStorageEvent(key, stringifiedValue); +}; + +const removeLocalStorageItem = (key: string) => { + window.localStorage.removeItem(key); + dispatchStorageEvent(key, null); +}; + +const localStorageSubscribe = (cb: () => void) => { + window.addEventListener("storage", cb); + return () => window.removeEventListener("storage", cb); +}; + +export const useLocalStorage = (key: string, initialValue: T) => { + const getSnapshot = () => getLocalStorageItem(key); + const store = useSyncExternalStore(localStorageSubscribe, getSnapshot); + + const setState = useCallback( + (v: T) => { + try { + let nextState: T; + if (isFunction(v)) { + const parsedStore = store ? JSON.parse(store) : null; + nextState = v(parsedStore ?? initialValue); + } else { + nextState = v; + } + + if (nextState === undefined || nextState === null) { + removeLocalStorageItem(key); + } else { + setLocalStorageItem(key, nextState); + } + } catch (e) { + console.log(e); + } + }, + [key, store, initialValue], + ); + + useEffect(() => { + if ( + getLocalStorageItem(key) === null && + typeof initialValue !== "undefined" + ) { + setLocalStorageItem(key, initialValue); + } + }, [key, initialValue]); + + return { + current: store ? JSON.parse(store) : initialValue, + setItemValue: setState, + removeItem: () => removeLocalStorageItem(key), + }; +}; diff --git a/packages/hooks/src/hooks/useNavigatorShare.ts b/packages/hooks/src/hooks/useNavigatorShare.ts new file mode 100644 index 0000000..889c9b0 --- /dev/null +++ b/packages/hooks/src/hooks/useNavigatorShare.ts @@ -0,0 +1,57 @@ +interface IShareData { + title: string; + text: string; + url?: string; + files?: File[]; +} + +const errorMessages: Record = { + NotAllowedError: "Permission to share denied.", + AbortError: "The sharing action was aborted.", + NotSupportedError: "Your browser does not support the sharing feature.", + TypeError: "Error while sharing: incorrect data type.", +}; + +function checkPermission(files?: File[]) { + if (!navigator.canShare) { + throw new Error("Your browser does not support the sharing feature."); + } + + if (!navigator.canShare(files ? { files } : { files: [new File([], "")] })) { + throw new Error( + `Your browser does not allow sharing ${files ? "this type of " : ""} files.`, + ); + } +} + +function surroundTryCatch(fn: (data: IShareData) => void | Promise) { + return async (data: IShareData) => { + try { + await fn(data); + } catch (error: unknown) { + if ((error as Error).name in errorMessages) { + const message = `Error while sharing: ${errorMessages[(error as Error).name]}`; + console.error(message); + } else { + throw error; + } + } + }; +} + +export const useNavigatorShare = () => { + async function shareInNavigator(data: IShareData) { + if (data.files) checkPermission(data.files); + + await navigator.share({ + title: data.title, + text: data.text ?? "", + url: data.url ?? "", + files: data.files ?? [], + }); + } + + return { + shareInNavigator: surroundTryCatch(shareInNavigator), + }; +}; diff --git a/packages/hooks/src/hooks/useOffline.ts b/packages/hooks/src/hooks/useOffline.ts new file mode 100644 index 0000000..740309b --- /dev/null +++ b/packages/hooks/src/hooks/useOffline.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +type TUseOffline = () => boolean; + +export const useOffline: TUseOffline = () => { + const [offline, setOffline] = useState(null); + + useEffect(() => { + const handleNetworkState = () => { + setOffline(!offline); + }; + addEventListener("offline", handleNetworkState); + addEventListener("online", handleNetworkState); + + return () => { + removeEventListener("online", handleNetworkState); + removeEventListener("offline", handleNetworkState); + }; + }, [offline]); + + return !!offline; +}; diff --git a/packages/hooks/src/hooks/useOnScreen.ts b/packages/hooks/src/hooks/useOnScreen.ts new file mode 100644 index 0000000..818b549 --- /dev/null +++ b/packages/hooks/src/hooks/useOnScreen.ts @@ -0,0 +1,25 @@ +import { type RefObject, useEffect, useState } from "react"; + +export default function useOnScreen( + ref: RefObject, + rootMargin = "0px", +): boolean { + const [isIntersecting, setIntersecting] = useState(false); + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => setIntersecting(entry.isIntersecting), + { rootMargin }, + ); + + if (ref.current) { + observer.observe(ref.current); + } + + return () => { + observer.disconnect(); + }; + }, [ref, rootMargin]); + + return isIntersecting; +} diff --git a/packages/hooks/src/hooks/useOutsideClick.ts b/packages/hooks/src/hooks/useOutsideClick.ts new file mode 100644 index 0000000..990c867 --- /dev/null +++ b/packages/hooks/src/hooks/useOutsideClick.ts @@ -0,0 +1,20 @@ +import { useEffect, type MutableRefObject } from "react"; + +export const useOutsideClick = ( + ref: MutableRefObject, + fn: () => void, +) => { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + fn(); + } + }; + + document.addEventListener("click", handleClickOutside); + + return () => { + document.removeEventListener("click", handleClickOutside); + }; + }, [ref, fn]); +}; diff --git a/packages/hooks/src/hooks/usePrevious.ts b/packages/hooks/src/hooks/usePrevious.ts new file mode 100644 index 0000000..73ff94a --- /dev/null +++ b/packages/hooks/src/hooks/usePrevious.ts @@ -0,0 +1,13 @@ +import { useRef } from "react"; + +export default function usePrevious(value: T): T | undefined { + const currentRef = useRef(value); + const previousRef = useRef(); + + if (currentRef.current !== value) { + previousRef.current = currentRef.current; + currentRef.current = value; + } + + return previousRef.current; +} diff --git a/packages/hooks/src/hooks/useRandomColor.ts b/packages/hooks/src/hooks/useRandomColor.ts new file mode 100644 index 0000000..fc53abc --- /dev/null +++ b/packages/hooks/src/hooks/useRandomColor.ts @@ -0,0 +1,16 @@ +import { useState } from "react"; + +export const useRandomColor = (initialColor?: string) => { + const [color, setColor] = useState(initialColor ?? "#000000"); + + const generateColor = () => { + const newColor = `#${Math.floor(Math.random() * 16777215) + .toString(16) + .padStart(6, "0")}`; + + setColor(newColor); + return newColor; + }; + + return { color, generateColor }; +}; diff --git a/packages/hooks/src/hooks/useScript.ts b/packages/hooks/src/hooks/useScript.ts new file mode 100644 index 0000000..689a671 --- /dev/null +++ b/packages/hooks/src/hooks/useScript.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +export const useScript = (url: string) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const script = document.createElement("script"); + script.src = url; + script.async = true; + + script.onload = () => { + setLoading(false); + }; + + script.onerror = () => { + setError(`Failed to load script ${url}`); + setLoading(false); + }; + + document.body.appendChild(script); + + return () => { + document.body.removeChild(script); + }; + }, [url]); + + return { loading, error }; +}; diff --git a/packages/hooks/src/hooks/useScroll.ts b/packages/hooks/src/hooks/useScroll.ts new file mode 100644 index 0000000..5a798d4 --- /dev/null +++ b/packages/hooks/src/hooks/useScroll.ts @@ -0,0 +1,25 @@ +import { useState, useLayoutEffect } from "react"; + +export const useScroll = () => { + const [position, setPosition] = useState({ + x: 0, + y: 0, + }); + + const handleScroll = () => { + setPosition({ + x: window.scrollX, + y: window.scrollY, + }); + }; + + useLayoutEffect(() => { + window.addEventListener("scroll", handleScroll); + + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, []); + + return { position, scrollTo: window.scrollTo }; +}; diff --git a/packages/hooks/src/hooks/useSearchParams.ts b/packages/hooks/src/hooks/useSearchParams.ts new file mode 100644 index 0000000..c6eb3a1 --- /dev/null +++ b/packages/hooks/src/hooks/useSearchParams.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; + +/* eslint-disable-next-line */ +type TUseSearchParams = >( + url?: string, + opt?: { unique: boolean }, +) => T; +export const useSearchParams: TUseSearchParams = ( + url = location.href, + opt = { unique: true }, +) => { + const _urlSearch = new URL(url); + const [params, setParams] = useState>(() => + // @ts-ignore + Object.fromEntries(_urlSearch.searchParams.entries()), + ); + + useEffect(() => { + const len: number = Object.values(params).length; + if (!opt || opt.unique || len === _urlSearch.searchParams?.size) return; + // @ts-ignore + for (const [key, value] of _urlSearch.searchParams) { + if (value === params?.[key]) continue; + if ( + Array.isArray(params?.[key]) && + Array.from(params?.[key]).includes(value) + ) + continue; + setParams(() => ({ + ...params, + [key]: [...(params?.[key] ?? []), value], + })); + } + }, []); + + return Object.fromEntries( + Object.entries(params).map(([key, value]) => [ + key, + !Array.isArray(value) + ? JSON.parse(value) + : value.map((items) => JSON.parse(items)), + ]), + ) as T; +}; diff --git a/packages/hooks/src/hooks/useStopwatch.ts b/packages/hooks/src/hooks/useStopwatch.ts new file mode 100644 index 0000000..a44dfa9 --- /dev/null +++ b/packages/hooks/src/hooks/useStopwatch.ts @@ -0,0 +1,97 @@ +import { useState, useEffect } from "react"; + +const addLeadingZero = (digit: number): string => { + let timeStr = ""; + + digit % 10 === digit ? (timeStr += `0${digit}`) : (timeStr += `${digit}`); + + return timeStr; +}; + +interface Stopwatch { + current: string; + isPaused: boolean; + isOver: boolean; + currentDays: number; + currentHours: number; + currentMinutes: number; + currentSeconds: number; + elapsedSeconds: number; + pause: () => void; + play: () => void; + reset: () => void; + togglePause: () => void; +} + +export const useStopwatch = (): Stopwatch => { + const [time, setTime] = useState({ + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }); + const [paused, setPaused] = useState(false); + const divider = ":"; + const [isOver, setIsOver] = useState(false); + + useEffect(() => { + if (paused) { + return; + } + + const interval = setInterval(() => { + setTime((prev) => { + let d = prev.days; + let h = prev.hours; + let m = prev.minutes; + let s = prev.seconds; + + if (s + 1 >= 60) { + s = 0; + if (m + 1 >= 60) { + m = 0; + if (h + 1 >= 24) { + h = 0; + d++; + } else { + h++; + } + } else { + m++; + } + } else { + s++; + } + + return { days: d, hours: h, minutes: m, seconds: s }; + }); + }, 1000); + + return () => clearInterval(interval); + }, [time, paused]); + + return { + current: `${addLeadingZero(time.days)}${divider}${addLeadingZero( + time.hours, + )}${divider}${addLeadingZero(time.minutes)}${divider}${addLeadingZero( + time.seconds, + )}`, + isPaused: paused, + isOver, + currentDays: time.days, + currentHours: time.hours, + currentMinutes: time.minutes, + currentSeconds: time.seconds, + elapsedSeconds: + time.days * 86400 + time.hours * 3600 + time.minutes * 60 + time.seconds, + pause: () => setPaused(true), + play: () => setPaused(false), + reset: () => { + setIsOver(false); + setTime({ days: 0, hours: 0, minutes: 0, seconds: 0 }); + }, + togglePause: () => { + setPaused(!paused); + }, + }; +}; diff --git a/packages/hooks/src/hooks/useTimer.ts b/packages/hooks/src/hooks/useTimer.ts new file mode 100644 index 0000000..278102c --- /dev/null +++ b/packages/hooks/src/hooks/useTimer.ts @@ -0,0 +1,126 @@ +import { useState, useEffect } from "react"; + +const parseTime = (time: string) => { + const splitTime = time.split(":"); + + const [days, hours, minutes, seconds] = splitTime.map((value) => + Number(value), + ); + + return { days, hours, minutes, seconds }; +}; + +const addLeadingZero = (digit: number): string => { + let timeStr = ""; + + digit % 10 === digit ? (timeStr += `0${digit}`) : (timeStr += `${digit}`); + + return timeStr; +}; + +interface Timer { + current: string; + isPaused: boolean; + isOver: boolean; + currentDays: number; + currentHours: number; + currentMinutes: number; + currentSeconds: number; + elapsedSeconds: number; + remainingSeconds: number; + pause: () => void; + play: () => void; + reset: () => void; + togglePause: () => void; +} + +export const useTimer = (startTime: string): Timer => { + const { days, hours, minutes, seconds } = parseTime(startTime); + const [time, setTime] = useState({ days, hours, minutes, seconds }); + const [paused, setPaused] = useState(false); + const divider = ":"; + const [isOver, setIsOver] = useState(false); + + useEffect(() => { + if (paused) { + return; + } + + const interval = setInterval(() => { + setTime((prev) => { + let d = prev.days; + let h = prev.hours; + let m = prev.minutes; + let s = prev.seconds; + + if (s - 1 < 0) { + s = 59; + if (m - 1 < 0) { + m = 59; + if (h - 1 < 0) { + h = 23; + if (d - 1 >= 0) { + d--; + } + } else { + h--; + } + } else { + m--; + } + } else { + s--; + } + + return { days: d, hours: h, minutes: m, seconds: s }; + }); + }, 1000); + + if ( + time.seconds === 0 && + time.minutes === 0 && + time.hours === 0 && + time.days === 0 + ) { + setIsOver(true); + clearInterval(interval); + return; + } + + return () => clearInterval(interval); + }, [days, hours, minutes, seconds, time, paused]); + + return { + current: `${addLeadingZero(time.days)}${divider}${addLeadingZero( + time.hours, + )}${divider}${addLeadingZero(time.minutes)}${divider}${addLeadingZero( + time.seconds, + )}`, + isPaused: paused, + isOver, + currentDays: time.days, + currentHours: time.hours, + currentMinutes: time.minutes, + currentSeconds: time.seconds, + elapsedSeconds: + days * 86400 + + hours * 3600 + + minutes * 60 + + seconds - + (time.days * 86400 + + time.hours * 3600 + + time.minutes * 60 + + time.seconds), + remainingSeconds: + time.days * 86400 + time.hours * 3600 + time.minutes * 60 + time.seconds, + pause: () => setPaused(true), + play: () => setPaused(false), + reset: () => { + setIsOver(false); + setTime({ days, hours, minutes, seconds }); + }, + togglePause: () => { + setPaused(!paused); + }, + }; +}; diff --git a/packages/hooks/src/hooks/useTitle.ts b/packages/hooks/src/hooks/useTitle.ts new file mode 100644 index 0000000..4375141 --- /dev/null +++ b/packages/hooks/src/hooks/useTitle.ts @@ -0,0 +1,12 @@ +import { useState } from "react"; + +export const useTitle = () => { + const [title, setTitle] = useState(document.title); + + const changeTitle = (newTitle: string) => { + document.title = newTitle; + setTitle(newTitle); + }; + + return { title, changeTitle }; +}; diff --git a/packages/hooks/src/hooks/useToggle.ts b/packages/hooks/src/hooks/useToggle.ts new file mode 100644 index 0000000..fe65088 --- /dev/null +++ b/packages/hooks/src/hooks/useToggle.ts @@ -0,0 +1,9 @@ +import { useState } from "react"; + +export const useToggle = (initialValue: boolean) => { + const [current, setCurrent] = useState(initialValue); + + const handleToggle = () => setCurrent((prev) => !prev); + + return { current, handleToggle }; +}; diff --git a/packages/hooks/src/hooks/useVideo.ts b/packages/hooks/src/hooks/useVideo.ts new file mode 100644 index 0000000..2ee32ed --- /dev/null +++ b/packages/hooks/src/hooks/useVideo.ts @@ -0,0 +1,170 @@ +import { useEffect, useState, type RefObject } from "react"; + +export const useVideo = (ref: RefObject) => { + const video = ref.current; + + const [videoState, setVideoState] = useState({ + isPaused: video ? video?.paused : true, + isMuted: video ? video?.muted : false, + currentVolume: video ? video?.volume : 100, + currentTime: video ? video?.currentTime : 0, + }); + + const play = () => { + video?.play(); + setVideoState((prev) => { + return { + ...prev, + isPaused: false, + isMuted: video ? video.muted : prev.isMuted, + }; + }); + }; + + const pause = () => { + video?.pause(); + setVideoState((prev) => { + return { + ...prev, + isPaused: true, + }; + }); + }; + + const handlePlayPauseControl = (e: Event) => { + setVideoState((prev) => { + return { + ...prev, + isPaused: (e.target as HTMLVideoElement).paused, + }; + }); + }; + + const togglePause = () => (video?.paused ? play() : pause()); + + const handleVolume = (delta: number) => { + const deltaDecimal = delta / 100; + + if (video) { + let newVolume = video?.volume + deltaDecimal; + + if (newVolume >= 1) { + newVolume = 1; + } else if (newVolume <= 0) { + newVolume = 0; + } + + video.volume = newVolume; + setVideoState((prev) => { + return { + ...prev, + currentVolume: newVolume * 100, + }; + }); + } + }; + + const handleVolumeControl = (e: Event) => { + if (e.target && video) { + const newVolume = (e.target as HTMLVideoElement).volume * 100; + + if (newVolume === videoState.currentVolume) { + handleMute(video.muted); + return; + } + + setVideoState((prev) => ({ + ...prev, + currentVolume: (e.target as HTMLVideoElement).volume * 100, + })); + } + }; + + const handleMute = (mute: boolean) => { + if (video) { + video.muted = mute; + setVideoState((prev) => { + return { + ...prev, + isMuted: mute, + }; + }); + } + }; + + const handleTime = (delta = 5) => { + if (video) { + let newTime = video.currentTime + delta; + + if (newTime >= video.duration) { + newTime = video.duration; + } else if (newTime <= 0) { + newTime = 0; + } + + video.currentTime = newTime; + setVideoState((prev) => { + return { + ...prev, + currentTime: newTime, + }; + }); + } + }; + + const handleTimeControl = (e: Event) => { + setVideoState((prev) => { + return { + ...prev, + currentTime: (e.target as HTMLVideoElement).currentTime, + }; + }); + }; + + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + video?.requestFullscreen().catch((err) => { + console.log(err); + }); + } else { + document.exitFullscreen(); + } + }; + + useEffect(() => { + return () => { + pause(); + }; + }, []); + + useEffect(() => { + if (video) { + video.addEventListener("volumechange", handleVolumeControl); + video.addEventListener("play", handlePlayPauseControl); + video.addEventListener("pause", handlePlayPauseControl); + video.addEventListener("timeupdate", handleTimeControl); + + return () => { + video.removeEventListener("volumechange", handleVolumeControl); + video.removeEventListener("play", handlePlayPauseControl); + video.removeEventListener("pause", handlePlayPauseControl); + video.removeEventListener("timeupdate", handleTimeControl); + }; + } + }, [video]); + + return { + ...videoState, + play, + pause, + togglePause, + increaseVolume: (increase = 5) => handleVolume(increase), + decreaseVolume: (decrease = 5) => handleVolume(decrease * -1), + mute: () => handleMute(true), + unmute: () => handleMute(false), + toggleMute: () => handleMute(!video?.muted), + forward: (increase = 5) => handleTime(increase), + back: (decrease = 5) => handleTime(decrease * -1), + toggleFullscreen, + }; +}; diff --git a/packages/hooks/src/hooks/useWindowSize.ts b/packages/hooks/src/hooks/useWindowSize.ts new file mode 100644 index 0000000..117769a --- /dev/null +++ b/packages/hooks/src/hooks/useWindowSize.ts @@ -0,0 +1,28 @@ +import { useState, useEffect } from "react"; + +type WindowSize = { + width: number; + height: number; +}; + +export const useWindowSize = (): WindowSize => { + const [windowSize, setWindowSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); + + const handleResize = () => { + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }; + + useEffect(() => { + window.addEventListener("resize", handleResize); + + return () => window.removeEventListener("resize", handleResize); + }, []); + + return windowSize; +}; diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts new file mode 100644 index 0000000..d4eb224 --- /dev/null +++ b/packages/hooks/src/index.ts @@ -0,0 +1,36 @@ +export * from "./hooks/useArray"; +export * from "./hooks/useAsync"; +export * from "./hooks/useAudio"; +export * from "./hooks/useBattery"; +export * from "./hooks/useClipboard"; +export * from "./hooks/useCountdown"; +export * from "./hooks/useCountup"; +export * from "./hooks/useDebounce"; +export * from "./hooks/useDownload"; +export * from "./hooks/useEventListener"; +export * from "./hooks/useFavicon"; +export * from "./hooks/useFetch"; +export * from "./hooks/useFirstRender"; +export * from "./hooks/useFirstVisit"; +export * from "./hooks/useGeolocation"; +export * from "./hooks/useHover"; +export * from "./hooks/useInput"; +export * from "./hooks/useIsTouchDevice"; +export * from "./hooks/useKeyPress"; +export * from "./hooks/useLang"; +export * from "./hooks/useLocalStorage"; +export * from "./hooks/useNavigatorShare"; +export * from "./hooks/useOffline"; +export * from "./hooks/useOnScreen"; +export * from "./hooks/useOutsideClick"; +export * from "./hooks/usePrevious"; +export * from "./hooks/useRandomColor"; +export * from "./hooks/useScript"; +export * from "./hooks/useScroll"; +export * from "./hooks/useSearchParams"; +export * from "./hooks/useStopwatch"; +export * from "./hooks/useTimer"; +export * from "./hooks/useTitle"; +export * from "./hooks/useToggle"; +export * from "./hooks/useVideo"; +export * from "./hooks/useWindowSize"; diff --git a/packages/hooks/src/main.tsx b/packages/hooks/src/main.tsx new file mode 100644 index 0000000..f19bf25 --- /dev/null +++ b/packages/hooks/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; + +// biome-ignore lint/style/noNonNullAssertion: +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/hooks/tsconfig.json b/packages/hooks/tsconfig.json new file mode 100644 index 0000000..dbc5bfd --- /dev/null +++ b/packages/hooks/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["DOM", "ESNext"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17672a3..1fd2338 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,12 @@ catalogs: '@biomejs/biome': specifier: ^1.9.4 version: 1.9.4 + '@types/react': + specifier: ^18.3.13 + version: 18.3.13 + react: + specifier: ^18.3.1 + version: 18.3.1 typescript: specifier: ^5.7.2 version: 5.7.2 @@ -27,10 +33,23 @@ importers: version: 5.7.2 packages/hooks: + dependencies: + react: + specifier: 'catalog:' + version: 18.3.1 devDependencies: '@biomejs/biome': specifier: 'catalog:' version: 1.9.4 + '@types/react': + specifier: 'catalog:' + version: 18.3.13 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) typescript: specifier: 'catalog:' version: 5.7.2 @@ -90,6 +109,37 @@ packages: cpu: [x64] os: [win32] + '@types/prop-types@15.7.13': + resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} + + '@types/react-dom@18.3.1': + resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==} + + '@types/react@18.3.13': + resolution: {integrity: sha512-ii/gswMmOievxAJed4PAHT949bpYjPKXvXo1v6cRB/kqc2ZR4n+SgyCyvyc5Fec5ez8VnUumI1Vk7j6fRyRogg==} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + typescript@5.7.2: resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} engines: {node: '>=14.17'} @@ -132,4 +182,37 @@ snapshots: '@biomejs/cli-win32-x64@1.9.4': optional: true + '@types/prop-types@15.7.13': {} + + '@types/react-dom@18.3.1': + dependencies: + '@types/react': 18.3.13 + + '@types/react@18.3.13': + dependencies: + '@types/prop-types': 15.7.13 + csstype: 3.1.3 + + csstype@3.1.3: {} + + js-tokens@4.0.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + typescript@5.7.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 29a0710..b58e107 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,5 @@ packages: catalog: typescript: ^5.7.2 "@biomejs/biome": ^1.9.4 + react: ^18.3.1 + "@types/react": ^18.3.13