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