first
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "hooks",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts",
|
||||
"./useAuth": "./src/useAuth/index.ts",
|
||||
"./useQueryState": "./src/useQueryState/index.ts",
|
||||
"./useForm": "./src/useForm/index.ts",
|
||||
"./useFullscreen": "./src/useFullscreen/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"config": "workspace:*",
|
||||
"helpers": "workspace:*",
|
||||
"types": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export { useGlobal } from './useGlobal';
|
||||
export { useClient, createClient } from './useClient';
|
||||
export { useDebounce } from './useDebounce';
|
||||
export { useDragAndDrop } from './useDragAndDrop';
|
||||
export { useImageLoader } from './useImageLoader';
|
||||
export { useIsMobile } from './useIsMobile';
|
||||
export { useLocalStorageState } from './useLocalStorageState';
|
||||
export { useMounted } from './useMounted';
|
||||
export { usePhotoEditor } from './usePhotoEditor';
|
||||
export { usePopover } from './usePopover';
|
||||
export { useQueryState } from './useQueryState';
|
||||
export { useTimeout } from './useTimeout';
|
||||
export { useTimer } from './useTimer';
|
||||
export { useWebsockets } from './useWebsockets';
|
||||
export { useChatWebSocket } from './useChatWebSocket';
|
||||
export { useDataControl } from './useDataControl';
|
||||
export { useCustomSorter } from './useCustomSorter';
|
||||
|
||||
export { useAuth } from './useAuth';
|
||||
export { useForm } from './useForm';
|
||||
export { useFullscreen } from './useFullscreen';
|
||||
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
export type StateSetter<T> = Dispatch<SetStateAction<T>>;
|
||||
|
||||
export type UserCountMessage = {
|
||||
type: 'users';
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type WelcomeMessage = {
|
||||
type: 'welcome';
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type BroadcastMessage = {
|
||||
type: string;
|
||||
count?: number;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type WebSocketMessage = UserCountMessage | WelcomeMessage | Record<string, unknown>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useAuth';
|
||||
@@ -0,0 +1,13 @@
|
||||
export type SignupUserForm = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type UpdateUserPayload = { name: string; avatar: string };
|
||||
|
||||
export type ResetPasswordPayload = { password: string; verificationCode: string };
|
||||
|
||||
export type ChangePasswordPayload = { password: string; newPassword: string; confirmPassword: string };
|
||||
|
||||
export type ForgotPasswordPayload = { email: string };
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { User } from 'types';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { usePasskeys } from './usePasskeys';
|
||||
import { config } from 'config';
|
||||
|
||||
type UseAuthProps = { authUrl?: string; apiUrl?: string } | undefined;
|
||||
export type UserWithToken = User & { token: string };
|
||||
|
||||
export const useAuth = (props: UseAuthProps = {}) => {
|
||||
const { authUrl = config.AUTH_URL, apiUrl = config.API_URL } = props;
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser, refreshUser] = useGlobal<UserWithToken | null>('CURRENT_USER', null);
|
||||
const authClient = useClient(authUrl);
|
||||
const apiClient = useClient(apiUrl);
|
||||
const passKeyManager = usePasskeys();
|
||||
|
||||
const { data: setupData } = useQuery({
|
||||
queryKey: ['AUTH_SETUP'],
|
||||
queryFn: () => apiClient.get<{ registrationOpen: boolean }>('/server-settings'),
|
||||
});
|
||||
const registrationOpen = setupData?.registrationOpen ?? false;
|
||||
|
||||
const { isLoading } = useQuery<UserWithToken | null>({
|
||||
queryKey: ['CURRENT_USER'],
|
||||
refetchOnMount: false,
|
||||
queryFn: async () => {
|
||||
const token =
|
||||
window.officerBearerToken ||
|
||||
document.body.dataset['officerBearerToken'] ||
|
||||
document.body.dataset['bearerToken'] ||
|
||||
new URL(window.location.href).searchParams.get('officerToken') ||
|
||||
localStorage.getItem('BEARER_TOKEN') ||
|
||||
sessionStorage.getItem('BEARER_TOKEN');
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const dbUser = (await authClient.get('/me')) as UserWithToken;
|
||||
setUser({ ...dbUser, token });
|
||||
return { ...dbUser, token } as UserWithToken;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const signin = async ({ email, password }: { email: string; password: string }) => {
|
||||
localStorage.removeItem('BEARER_TOKEN');
|
||||
const data = await authClient.post('/signin', { email, password });
|
||||
|
||||
const { user, token } = data;
|
||||
|
||||
if (!token && user.passkeys > 0) {
|
||||
await passKeyManager.signinWithPasskey(user);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('BEARER_TOKEN', data.token);
|
||||
queryClient.invalidateQueries({ queryKey: ['CURRENT_USER'] });
|
||||
return data.token;
|
||||
};
|
||||
|
||||
const signout = async () => {
|
||||
// Invalidate token on server (best-effort, don't block on failure)
|
||||
try {
|
||||
await authClient.post('/signout');
|
||||
} catch {
|
||||
// Token may already be invalid, continue with local cleanup
|
||||
}
|
||||
setUser(null);
|
||||
localStorage.removeItem('BEARER_TOKEN');
|
||||
localStorage.removeItem('CURRENT_USER');
|
||||
};
|
||||
|
||||
const signup = async (newUser: SignupUserForm) => {
|
||||
return await authClient.post('/signup', newUser);
|
||||
};
|
||||
|
||||
const resetPassword = async (payload: ResetPasswordPayload) => {
|
||||
await authClient.post('/reset-password', payload);
|
||||
};
|
||||
|
||||
const verify = async (payload: VerifyPayload) => {
|
||||
const data = await authClient.post('/verify', payload);
|
||||
if (data.token) {
|
||||
localStorage.setItem('BEARER_TOKEN', data.token);
|
||||
queryClient.invalidateQueries({ queryKey: ['CURRENT_USER'] });
|
||||
}
|
||||
};
|
||||
|
||||
const updateUser = async (payload: UpdateUserPayload) => {
|
||||
await apiClient.put('/users', payload);
|
||||
localStorage.removeItem('CURRENT_USER');
|
||||
refreshUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['CURRENT_USER'] });
|
||||
};
|
||||
|
||||
const changePassword = async (payload: ChangePasswordPayload) => {
|
||||
const data = await authClient.post('/change-password', payload);
|
||||
if (data.token) {
|
||||
localStorage.setItem('BEARER_TOKEN', data.token);
|
||||
queryClient.invalidateQueries({ queryKey: ['CURRENT_USER'] });
|
||||
}
|
||||
};
|
||||
|
||||
const forgotPassword = async (payload: ForgotPasswordPayload) => {
|
||||
await authClient.post('/forgot-password', payload);
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
registrationOpen,
|
||||
refreshUser,
|
||||
signin,
|
||||
signout,
|
||||
signup,
|
||||
verify,
|
||||
resetPassword,
|
||||
updateUser,
|
||||
changePassword,
|
||||
forgotPassword,
|
||||
...passKeyManager,
|
||||
};
|
||||
};
|
||||
|
||||
export type SignupUserForm = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type UpdateUserPayload = {
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
export type ResetPasswordPayload = {
|
||||
password: string;
|
||||
verificationCode: string;
|
||||
};
|
||||
|
||||
export type ChangePasswordPayload = {
|
||||
password: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
export type VerifyPayload = {
|
||||
verificationCode: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
export type ForgotPasswordPayload = { email: string };
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { config } from 'config';
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
|
||||
import type {
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
} from '@simplewebauthn/browser';
|
||||
|
||||
type PasskeyUser = {
|
||||
id: number;
|
||||
email: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
};
|
||||
|
||||
export const usePasskeys = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const authClient = useClient(config.AUTH_URL);
|
||||
|
||||
const createPasskeyCredentials = async (user: PasskeyUser) => {
|
||||
// Get registration options from server
|
||||
const options = (await authClient.post(
|
||||
`/passkeys/challenge/${user.email}`,
|
||||
)) as PublicKeyCredentialCreationOptionsJSON;
|
||||
|
||||
// Create passkey using browser API (handled by SimpleWebAuthn)
|
||||
const registrationResponse = await startRegistration({ optionsJSON: options });
|
||||
|
||||
// Send full response to server for verification
|
||||
await authClient.post(`/passkeys/credentials`, registrationResponse);
|
||||
queryClient.invalidateQueries({ queryKey: ['CURRENT_USER'] });
|
||||
};
|
||||
|
||||
const signinWithPasskey = async (user: PasskeyUser) => {
|
||||
// Get authentication options from server
|
||||
const options = (await authClient.get(`/passkeys/signin/${user.email}`)) as PublicKeyCredentialRequestOptionsJSON;
|
||||
|
||||
// Authenticate using browser API (handled by SimpleWebAuthn)
|
||||
const authenticationResponse = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
// Send full response to server for verification
|
||||
const url = `/passkeys/verify/${user.email}`;
|
||||
const data = (await authClient.post(url, authenticationResponse)) as { token: string };
|
||||
localStorage.setItem('BEARER_TOKEN', data.token);
|
||||
queryClient.invalidateQueries({ queryKey: ['CURRENT_USER'] });
|
||||
return data;
|
||||
};
|
||||
|
||||
const authorizeWithPasskey = async (user: PasskeyUser) => {
|
||||
// Get authentication options from server
|
||||
const options = (await authClient.get(`/passkeys/signin/${user.email}`)) as PublicKeyCredentialRequestOptionsJSON;
|
||||
|
||||
// Authenticate using browser API (handled by SimpleWebAuthn)
|
||||
const authenticationResponse = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
// Return the credential ID for authorization purposes
|
||||
return authenticationResponse.id;
|
||||
};
|
||||
|
||||
return { createPasskeyCredentials, signinWithPasskey, authorizeWithPasskey };
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
type UseChatWebSocketParams = {
|
||||
url: string;
|
||||
onMessage: (data: unknown) => void;
|
||||
};
|
||||
|
||||
export const useChatWebSocket = ({ url, onMessage }: UseChatWebSocketParams) => {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const retryRef = useRef(0);
|
||||
const retryTimeoutRef = useRef<number | null>(null);
|
||||
const isCleaningUpRef = useRef(false);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
onMessageRef.current = onMessage;
|
||||
|
||||
const connect = () => {
|
||||
if (isCleaningUpRef.current) return;
|
||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const socket = new WebSocket(url);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
setIsConnected(true);
|
||||
retryRef.current = 0;
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
|
||||
onMessageRef.current(data);
|
||||
} catch {
|
||||
// ignore unparseable messages
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (isCleaningUpRef.current) return;
|
||||
if (socketRef.current !== socket) return;
|
||||
setIsConnected(false);
|
||||
const retry = (retryRef.current ?? 0) + 1;
|
||||
retryRef.current = retry;
|
||||
const delay = Math.min(5000, 300 * retry);
|
||||
retryTimeoutRef.current = window.setTimeout(connect, delay);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
socket.close();
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isCleaningUpRef.current = false;
|
||||
connect();
|
||||
return () => {
|
||||
isCleaningUpRef.current = true;
|
||||
if (retryTimeoutRef.current !== null) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
const send = (data: Record<string, unknown>) => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(JSON.stringify(data));
|
||||
};
|
||||
|
||||
return { isConnected, send };
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { config } from 'config';
|
||||
import { useGlobal } from './useGlobal';
|
||||
let theToken: string | null = null;
|
||||
|
||||
export const createClient = (baseUrl: string = config.API_URL) => {
|
||||
const lsToken =
|
||||
window.officerBearerToken ||
|
||||
document.body.dataset['officerBearerToken'] ||
|
||||
document.body.dataset['bearerToken'] ||
|
||||
new URL(window.location.href).searchParams.get('officerToken') ||
|
||||
localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ||
|
||||
localStorage.getItem('BEARER_TOKEN') ||
|
||||
sessionStorage.getItem('BEARER_TOKEN');
|
||||
|
||||
theToken = lsToken;
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token: theToken,
|
||||
get: <T>(url: string) => get<T>(url, baseUrl),
|
||||
getText: (url: string) => getText(url, baseUrl),
|
||||
getBlob: (url: string) => getBlob(url, baseUrl),
|
||||
// getStream: (url) => getStream(url, baseUrl),
|
||||
post: <T>(url: string, payload?: any) => post<T>(url, payload, baseUrl),
|
||||
put: <T>(url: string, payload?: any) => put<T>(url, payload, baseUrl),
|
||||
patch: <T>(url: string, payload?: any) => patch<T>(url, payload, baseUrl),
|
||||
delete: <T>(url: string, payload?: any) => DELETE<T>(url, payload, baseUrl),
|
||||
};
|
||||
};
|
||||
|
||||
export const useClient = (baseUrl: string = config.API_URL) => {
|
||||
const [apiError, setApiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
|
||||
useClient.config.onError = (error) => {
|
||||
setApiError(error);
|
||||
};
|
||||
|
||||
const client = createClient(baseUrl);
|
||||
return { ...client, apiError, setApiError };
|
||||
};
|
||||
|
||||
export const getHeaders = (isText: boolean = false) => {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (theToken) {
|
||||
headers['Authorization'] = `Bearer ${theToken}`;
|
||||
}
|
||||
|
||||
headers['Content-Type'] = isText ? 'text/plain' : 'application/json';
|
||||
|
||||
if (isText) {
|
||||
headers['Accept'] = 'text/plain';
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const getText = async (uri: string, baseUrl = '') => {
|
||||
const headers = getHeaders(true);
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, { headers });
|
||||
await validateResponse(res);
|
||||
const text = await res.text();
|
||||
return text;
|
||||
};
|
||||
|
||||
export const get = async <T>(uri: string, baseUrl = '') => {
|
||||
const headers = getHeaders();
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, { headers });
|
||||
await validateResponse(res);
|
||||
const data = await res.json();
|
||||
return data as T | any;
|
||||
};
|
||||
|
||||
export const getBlob = async (uri: string, baseUrl = '') => {
|
||||
const headers = { Authorization: `Bearer ${theToken!}` };
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, { headers });
|
||||
await validateResponse(res);
|
||||
const blob = await res.blob();
|
||||
return blob;
|
||||
};
|
||||
|
||||
// export const getStream = (uri: string, baseUrl = '') => {
|
||||
// const headers = getHeaders() as EventSourceInit;
|
||||
// const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
// const stream = new EventSource(theUrl, { headers });
|
||||
// return stream;
|
||||
// };
|
||||
|
||||
export const post = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
const headers: Record<string, string> = getHeaders();
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const body = payload instanceof FormData ? payload : JSON.stringify(payload);
|
||||
if (payload instanceof FormData) {
|
||||
delete headers['Content-Type'];
|
||||
}
|
||||
const res = await fetch(theUrl, {
|
||||
method: 'post',
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
await validateResponse(res);
|
||||
const data = await res.json();
|
||||
return data as T | any;
|
||||
};
|
||||
|
||||
export const put = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
const headers: any = getHeaders();
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const body = payload instanceof FormData ? payload : JSON.stringify(payload);
|
||||
|
||||
if (payload instanceof FormData) {
|
||||
delete headers['Content-Type'];
|
||||
}
|
||||
|
||||
const res = await fetch(theUrl, {
|
||||
method: 'put',
|
||||
body: body,
|
||||
headers,
|
||||
});
|
||||
await validateResponse(res);
|
||||
const data = await res.json();
|
||||
return data as T | any;
|
||||
};
|
||||
|
||||
export const patch = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
const headers = getHeaders();
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
headers,
|
||||
});
|
||||
await validateResponse(res);
|
||||
const data = await res.json();
|
||||
return data as T | any;
|
||||
};
|
||||
|
||||
export const DELETE = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
const headers = getHeaders();
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, {
|
||||
method: 'delete',
|
||||
body: JSON.stringify(payload),
|
||||
headers,
|
||||
});
|
||||
await validateResponse(res);
|
||||
const data = await res.json();
|
||||
return data as T | any;
|
||||
};
|
||||
|
||||
const validateResponse = async (res: Response) => {
|
||||
if (res.status >= 400) {
|
||||
const { onError } = useClient.config;
|
||||
const message = await res.text();
|
||||
if (onError) {
|
||||
onError({ status: res.status, message });
|
||||
}
|
||||
throw { status: res.status, message };
|
||||
}
|
||||
};
|
||||
|
||||
export interface ErrorDetails {
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface UseClientConfig {
|
||||
onError: (error: ErrorDetails) => void;
|
||||
}
|
||||
|
||||
// Assign the interface to the config property of useClient
|
||||
useClient.config = {
|
||||
onError: () => null,
|
||||
} as UseClientConfig;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useCustomSorter = <T>(sortedBy: SortedBy<T>) => {
|
||||
const defaultSorter = (a: T, b: T): number => {
|
||||
const [key, direction] = (sortedBy || '').split('-') as [Extract<keyof T, string>, SortDirection];
|
||||
const factor = direction === 'asc' ? 1 : -1;
|
||||
|
||||
const av = a?.[key];
|
||||
const bv = b?.[key];
|
||||
|
||||
const aNil = av == null;
|
||||
const bNil = bv == null;
|
||||
if (aNil && bNil) return 0;
|
||||
if (aNil) return 1;
|
||||
if (bNil) return -1;
|
||||
|
||||
if (typeof av === 'number' && typeof bv === 'number') {
|
||||
return (av - bv) * factor;
|
||||
}
|
||||
|
||||
if (av instanceof Date && bv instanceof Date) {
|
||||
return (av.getTime() - bv.getTime()) * factor;
|
||||
}
|
||||
|
||||
return (
|
||||
String(av).localeCompare(String(bv), undefined, {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
}) * factor
|
||||
);
|
||||
};
|
||||
|
||||
const [sortFunction, setSortFunction] = useState<(a: T, b: T, defaultSorter: (a: T, b: T) => number) => number>(
|
||||
() => (a: T, b: T, defaultSorter: (a: T, b: T) => number) => defaultSorter(a, b),
|
||||
);
|
||||
|
||||
return { defaultSorter, sortFunction, setSortFunction };
|
||||
};
|
||||
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
export type SortKey<T> = Extract<keyof T, string> | string;
|
||||
export type SortedBy<T> = `${SortKey<T>}-${SortDirection}` | null;
|
||||
@@ -0,0 +1,436 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDataControl } from './useDataControl';
|
||||
|
||||
type TestItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
value: number;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
const mockData: TestItem[] = [
|
||||
{ id: 1, name: 'Alpha', value: 100, createdAt: new Date('2024-01-01') },
|
||||
{ id: 2, name: 'Beta', value: 200, createdAt: new Date('2024-02-01') },
|
||||
{ id: 3, name: 'Gamma', value: 150, createdAt: new Date('2024-03-01') },
|
||||
{ id: 4, name: 'Delta', value: 50, createdAt: new Date('2024-04-01') },
|
||||
{ id: 5, name: 'Epsilon', value: 300, createdAt: new Date('2024-05-01') },
|
||||
{ id: 6, name: 'Zeta', value: 250, createdAt: new Date('2024-06-01') },
|
||||
{ id: 7, name: 'Eta', value: 175, createdAt: new Date('2024-07-01') },
|
||||
];
|
||||
|
||||
describe('useDataControl', () => {
|
||||
describe('initialization', () => {
|
||||
test('returns raw data unchanged', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
expect(result.current.rawData).toBe(mockData);
|
||||
});
|
||||
|
||||
test('handles null data', () => {
|
||||
const { result } = renderHook(() => useDataControl(null));
|
||||
expect(result.current.rawData).toBeNull();
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles undefined data', () => {
|
||||
const { result } = renderHook(() => useDataControl(undefined));
|
||||
expect(result.current.rawData).toBeUndefined();
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles empty array', () => {
|
||||
const { result } = renderHook(() => useDataControl<TestItem>([]));
|
||||
expect(result.current.rawData).toEqual([]);
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
test('defaults to page size of 5', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
expect(result.current.pageSize).toBe(5);
|
||||
expect(result.current.data?.length).toBe(5);
|
||||
});
|
||||
|
||||
test('setPageSize changes page size', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setPageSize(3);
|
||||
});
|
||||
|
||||
expect(result.current.pageSize).toBe(3);
|
||||
expect(result.current.data?.length).toBe(3);
|
||||
});
|
||||
|
||||
test('calculates correct page count', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
expect(result.current.pageCount).toBe(2); // 7 items / 5 per page = 2 pages
|
||||
|
||||
act(() => {
|
||||
result.current.setPageSize(3);
|
||||
});
|
||||
|
||||
expect(result.current.pageCount).toBe(3); // 7 items / 3 per page = 3 pages
|
||||
});
|
||||
|
||||
test('changePage navigates to different pages', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
expect(result.current.data?.[0]?.id).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.changePage(2);
|
||||
});
|
||||
|
||||
expect(result.current.currentPage).toBe(2);
|
||||
expect(result.current.data?.[0]?.id).toBe(6);
|
||||
});
|
||||
|
||||
test('changePage clamps to valid range', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.changePage(100);
|
||||
});
|
||||
expect(result.current.currentPage).toBe(2); // max page
|
||||
|
||||
act(() => {
|
||||
result.current.changePage(-5);
|
||||
});
|
||||
expect(result.current.currentPage).toBe(1); // min page
|
||||
});
|
||||
|
||||
test('resetPageSize restores default', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setPageSize(10);
|
||||
});
|
||||
expect(result.current.pageSize).toBe(10);
|
||||
|
||||
act(() => {
|
||||
result.current.resetPageSize();
|
||||
});
|
||||
expect(result.current.pageSize).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
test('sortBy sets ascending sort initially', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.sortBy('name');
|
||||
});
|
||||
|
||||
expect(result.current.sortedBy).toBe('name-asc');
|
||||
expect(result.current.sortKey).toBe('name');
|
||||
expect(result.current.sortDirection).toBe('asc');
|
||||
expect(result.current.data?.[0]?.name).toBe('Alpha');
|
||||
});
|
||||
|
||||
test('sortBy toggles to descending on second call', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.sortBy('name');
|
||||
});
|
||||
act(() => {
|
||||
result.current.sortBy('name');
|
||||
});
|
||||
|
||||
expect(result.current.sortedBy).toBe('name-desc');
|
||||
expect(result.current.sortDirection).toBe('desc');
|
||||
expect(result.current.data?.[0]?.name).toBe('Zeta');
|
||||
});
|
||||
|
||||
test('sortBy toggles back to ascending on third call', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.sortBy('name');
|
||||
});
|
||||
act(() => {
|
||||
result.current.sortBy('name');
|
||||
});
|
||||
act(() => {
|
||||
result.current.sortBy('name');
|
||||
});
|
||||
|
||||
expect(result.current.sortedBy).toBe('name-asc');
|
||||
});
|
||||
|
||||
test('sorts numbers correctly', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.sortBy('value');
|
||||
});
|
||||
|
||||
expect(result.current.data?.[0]?.value).toBe(50); // Delta
|
||||
expect(result.current.data?.[4]?.value).toBe(200); // Beta (5th in page)
|
||||
});
|
||||
|
||||
test('sorts dates correctly', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.sortBy('createdAt');
|
||||
});
|
||||
|
||||
expect(result.current.data?.[0]?.id).toBe(1); // January
|
||||
});
|
||||
|
||||
test('setSortedBy allows direct sort control', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setSortedBy('value-desc');
|
||||
});
|
||||
|
||||
expect(result.current.sortedBy).toBe('value-desc');
|
||||
expect(result.current.data?.[0]?.value).toBe(300); // Epsilon
|
||||
});
|
||||
|
||||
test('sorting resets to page 1', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.changePage(2);
|
||||
});
|
||||
expect(result.current.currentPage).toBe(2);
|
||||
|
||||
act(() => {
|
||||
result.current.sortBy('name');
|
||||
});
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
test('sortBy with null/undefined key does nothing', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.sortBy(null);
|
||||
});
|
||||
expect(result.current.sortedBy).toBeNull();
|
||||
|
||||
act(() => {
|
||||
result.current.sortBy(undefined);
|
||||
});
|
||||
expect(result.current.sortedBy).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searching', () => {
|
||||
test('setSearchQuery filters data', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchKeys(['name']);
|
||||
result.current.setSearchQuery('Alpha');
|
||||
});
|
||||
|
||||
expect(result.current.data?.length).toBe(1);
|
||||
expect(result.current.data?.[0]?.name).toBe('Alpha');
|
||||
});
|
||||
|
||||
test('search is case-insensitive', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchKeys(['name']);
|
||||
result.current.setSearchQuery('BETA');
|
||||
});
|
||||
|
||||
expect(result.current.data?.length).toBe(1);
|
||||
expect(result.current.data?.[0]?.name).toBe('Beta');
|
||||
});
|
||||
|
||||
test('search strips diacritics', () => {
|
||||
const dataWithAccents = [
|
||||
{ id: 1, name: 'Café' },
|
||||
{ id: 2, name: 'Naïve' },
|
||||
{ id: 3, name: 'Resume' },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useDataControl(dataWithAccents));
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchKeys(['name']);
|
||||
result.current.setSearchQuery('cafe');
|
||||
});
|
||||
|
||||
expect(result.current.data?.length).toBe(1);
|
||||
expect(result.current.data?.[0]?.name).toBe('Café');
|
||||
});
|
||||
|
||||
test('search works across multiple keys', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchKeys(['name', 'id']);
|
||||
result.current.setSearchQuery('5');
|
||||
});
|
||||
|
||||
// Should find id:5 (Epsilon) and value with 5 in name if any
|
||||
expect(result.current.data?.some((item) => item.id === 5)).toBe(true);
|
||||
});
|
||||
|
||||
test('search on numbers works', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchKeys(['value']);
|
||||
result.current.setSearchQuery('100');
|
||||
});
|
||||
|
||||
expect(result.current.data?.length).toBe(1);
|
||||
expect(result.current.data?.[0]?.value).toBe(100);
|
||||
});
|
||||
|
||||
test('empty search returns all data', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchKeys(['name']);
|
||||
result.current.setSearchQuery('Alpha');
|
||||
});
|
||||
expect(result.current.data?.length).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('');
|
||||
});
|
||||
expect(result.current.rawData?.length).toBe(7);
|
||||
});
|
||||
|
||||
test('search resets to page 1', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.changePage(2);
|
||||
});
|
||||
expect(result.current.currentPage).toBe(2);
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchKeys(['name']);
|
||||
result.current.setSearchQuery('a');
|
||||
});
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
test('no results when search has no matches', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchKeys(['name']);
|
||||
result.current.setSearchQuery('xyz123');
|
||||
});
|
||||
|
||||
expect(result.current.data?.length).toBe(0);
|
||||
});
|
||||
|
||||
test('search without searchKeys does not filter', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('Alpha');
|
||||
});
|
||||
|
||||
// No searchKeys set, so no filtering happens
|
||||
expect(result.current.data?.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined operations', () => {
|
||||
test('search + sort work together', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchKeys(['name']);
|
||||
result.current.setSearchQuery('a'); // Alpha, Beta, Gamma, Delta, Zeta, Eta
|
||||
result.current.sortBy('value');
|
||||
});
|
||||
|
||||
// Filtered by 'a' in name, sorted by value ascending
|
||||
const filtered = result.current.data;
|
||||
expect(filtered?.length).toBeGreaterThan(0);
|
||||
|
||||
// Check sorted order within filtered results
|
||||
for (let i = 1; i < (filtered?.length || 0); i++) {
|
||||
expect(filtered![i]!.value).toBeGreaterThanOrEqual(filtered![i - 1]!.value);
|
||||
}
|
||||
});
|
||||
|
||||
test('search + sort + pagination work together', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setPageSize(2);
|
||||
result.current.setSearchKeys(['name']);
|
||||
result.current.setSearchQuery('a');
|
||||
result.current.sortBy('name');
|
||||
});
|
||||
|
||||
// Should have multiple pages of results containing 'a'
|
||||
expect(result.current.pageCount).toBeGreaterThan(1);
|
||||
expect(result.current.data?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom sort', () => {
|
||||
test('setCustomSort allows custom sorting logic', () => {
|
||||
const { result } = renderHook(() => useDataControl(mockData));
|
||||
|
||||
act(() => {
|
||||
result.current.setCustomSort(() => (a: TestItem, b: TestItem) => {
|
||||
// Sort by name length
|
||||
return a.name.length - b.name.length;
|
||||
});
|
||||
result.current.sortBy('name'); // Trigger a sort
|
||||
});
|
||||
|
||||
// Eta (3), Beta (4), Zeta (4), Alpha (5), Gamma (5), Delta (5), Epsilon (7)
|
||||
expect(result.current.data?.[0]?.name).toBe('Eta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data reactivity', () => {
|
||||
test('updates when raw data changes', () => {
|
||||
const { result, rerender } = renderHook(({ data }) => useDataControl(data), {
|
||||
initialProps: { data: mockData },
|
||||
});
|
||||
|
||||
expect(result.current.rawData?.length).toBe(7);
|
||||
|
||||
const newData = mockData.slice(0, 3);
|
||||
rerender({ data: newData });
|
||||
|
||||
expect(result.current.rawData?.length).toBe(3);
|
||||
});
|
||||
|
||||
test('clamps current page when data shrinks', () => {
|
||||
const { result, rerender } = renderHook(({ data }) => useDataControl(data), {
|
||||
initialProps: { data: mockData },
|
||||
});
|
||||
|
||||
// Set page size first, then navigate in separate act
|
||||
act(() => {
|
||||
result.current.setPageSize(3);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.changePage(3); // Go to page 3 (7 items / 3 per page = 3 pages)
|
||||
});
|
||||
expect(result.current.currentPage).toBe(3);
|
||||
|
||||
// Shrink data to only 2 items
|
||||
rerender({ data: mockData.slice(0, 2) });
|
||||
|
||||
// Page should be clamped to 1 (2 items / 3 per page = 1 page)
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { SortKey, SortedBy } from './useCustomSorter';
|
||||
import { useCustomSorter } from './useCustomSorter';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 5;
|
||||
|
||||
export function useDataControl<T>(rawData: T[] | null | undefined) {
|
||||
const [pageSize, setPageSize] = useState<number>(DEFAULT_PAGE_SIZE);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [sortedBy, setSortedBy] = useState<SortedBy<T>>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [searchKeys, setSearchKeys] = useState<(keyof T)[] | undefined>(undefined);
|
||||
const { defaultSorter, sortFunction, setSortFunction } = useCustomSorter<T>(sortedBy);
|
||||
|
||||
const qNorm = stripDiacritics((searchQuery || '').trim());
|
||||
|
||||
const hasSearch = qNorm.length > 0 && Array.isArray(searchKeys) && searchKeys.length > 0;
|
||||
|
||||
const filtered = !hasSearch
|
||||
? rawData
|
||||
: rawData?.filter((item) =>
|
||||
(searchKeys as (keyof T)[]).some((key) => {
|
||||
const v = item[key];
|
||||
|
||||
if (typeof v === 'number') return v.toString().includes(qNorm);
|
||||
|
||||
const normalized = stripDiacritics(toSearchable(v));
|
||||
return normalized.includes(qNorm);
|
||||
}),
|
||||
);
|
||||
|
||||
const sorted = !sortedBy ? filtered : filtered && [...filtered].sort((a, b) => sortFunction(a, b, defaultSorter));
|
||||
|
||||
const sortBy = (key?: Extract<keyof T, string> | null) => {
|
||||
if (!key) return;
|
||||
if (sortedBy?.startsWith(`${String(key)}-`)) {
|
||||
const direction = sortedBy?.endsWith('-asc') ? 'desc' : 'asc';
|
||||
return setSortedBy(`${key}-${direction}`);
|
||||
}
|
||||
return setSortedBy(`${key}-asc`);
|
||||
};
|
||||
|
||||
const [sortKey, sortDirection] = (sortedBy || '').split('-') as [SortKey<T> | null, 'asc' | 'desc'] | [null, null];
|
||||
|
||||
const effectivePageSize = pageSize ?? (sorted?.length || 1);
|
||||
const pageCount = Math.max(1, Math.ceil((sorted?.length || 1) / effectivePageSize));
|
||||
|
||||
const currentPageClamped = Math.min(currentPage, pageCount);
|
||||
const start = (currentPageClamped - 1) * effectivePageSize;
|
||||
const end = start + effectivePageSize;
|
||||
const pageData = sorted?.slice(start, end);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [qNorm, searchKeys, sortedBy]);
|
||||
|
||||
const changePage = (p: number) => {
|
||||
const clamped = Math.min(Math.max(1, Math.floor(p)), pageCount);
|
||||
setCurrentPage(clamped);
|
||||
};
|
||||
return {
|
||||
rawData,
|
||||
data: pageData,
|
||||
currentPage: currentPageClamped,
|
||||
changePage,
|
||||
pageSize: effectivePageSize,
|
||||
setPageSize,
|
||||
resetPageSize: () => setPageSize(+DEFAULT_PAGE_SIZE),
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchKeys,
|
||||
setSearchKeys,
|
||||
setCustomSort: setSortFunction,
|
||||
pageCount,
|
||||
sortedBy,
|
||||
setSortedBy,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
sortBy,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseDataControlReturn<T> = ReturnType<typeof useDataControl<T>>;
|
||||
export type { SortKey, SortedBy };
|
||||
|
||||
const stripDiacritics = (s: string) =>
|
||||
s
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toLowerCase();
|
||||
|
||||
const toSearchable = (v: unknown) => {
|
||||
if (v == null) return '';
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
return String(v);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import useTimeout from './useTimeout';
|
||||
|
||||
export const useDebounce = (callback: any, delay: number, dependencies: any) => {
|
||||
const { reset, clear } = useTimeout(callback, delay);
|
||||
useEffect(reset, [...dependencies, reset]);
|
||||
useEffect(clear, []);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const useDragAndDrop = () => {
|
||||
const underlayRef = useRef<HTMLDivElement>(null);
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onMouseDown = (ev: MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!underlayRef.current) return;
|
||||
underlayRef.current.style.display = 'block';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const draggable = draggableRef.current;
|
||||
const trigger = triggerRef.current;
|
||||
if (!draggable || !trigger) return;
|
||||
const { clientX, clientY } = ev;
|
||||
const style = window.getComputedStyle(draggable);
|
||||
const marginTop = parseInt(style.getPropertyValue('margin-top'), 10);
|
||||
|
||||
draggable.style.left = clientX - trigger.clientWidth / 2 + 'px';
|
||||
draggable.style.top = clientY - trigger.clientHeight / 2 - marginTop + 'px';
|
||||
|
||||
// if (width + clientX <= window.innerWidth && clientX >= 20) {
|
||||
// draggable.style.left = clientX - 20 + 'px';
|
||||
// }
|
||||
// if (height + clientY <= window.innerHeight && clientY >= 20) {
|
||||
// draggable.style.top = clientY + 'px';
|
||||
// }
|
||||
};
|
||||
const onMouseUp = (ev: MouseEvent) => {
|
||||
ev && document.removeEventListener('mousemove', onMouseMove);
|
||||
if (!underlayRef.current) return;
|
||||
underlayRef.current.style.display = 'none';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const draggable = draggableRef.current;
|
||||
const trigger = triggerRef.current;
|
||||
if (draggable && trigger) {
|
||||
trigger.addEventListener('mousedown', onMouseDown);
|
||||
trigger.style.cursor = 'pointer';
|
||||
const underlay = document.getElementById('drag-and-drop-underlay') as HTMLDivElement;
|
||||
underlayRef.current = underlay || createUnderlay();
|
||||
document.body.appendChild(underlayRef.current);
|
||||
}
|
||||
}, [draggableRef]);
|
||||
|
||||
return { draggableRef, triggerRef };
|
||||
};
|
||||
|
||||
const createUnderlay = () => {
|
||||
const underlay = document.createElement('div');
|
||||
underlay.id = 'drag-and-drop-underlay';
|
||||
underlay.style.position = 'fixed';
|
||||
underlay.style.top = '0';
|
||||
underlay.style.left = '0';
|
||||
underlay.style.width = '100vw';
|
||||
underlay.style.height = '100vh';
|
||||
underlay.style.backgroundColor = 'rgba(0,0,0,0.0)';
|
||||
underlay.style.zIndex = '50';
|
||||
underlay.style.display = 'none';
|
||||
return underlay as HTMLDivElement;
|
||||
};
|
||||
|
||||
export type UseDragAndDropHook = ReturnType<typeof useDragAndDrop>;
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FillFormArgs, InputsType } from './types';
|
||||
|
||||
const dateFormat = (v: Date) => v.toISOString().slice(0, 10);
|
||||
|
||||
const fillForm = function (args: FillFormArgs) {
|
||||
const { theForm, state, path = [] } = args;
|
||||
if (!state) return;
|
||||
|
||||
Object.entries(state).forEach(([key, val]) => {
|
||||
if (typeof key !== 'string' || '' + parseInt(key, 10) == key) return;
|
||||
let target: InputsType | undefined = undefined;
|
||||
const targets = Array.from(document.querySelectorAll(`[name=${key}]`));
|
||||
if (targets.length === 1) {
|
||||
target = targets[0] as InputsType;
|
||||
} else {
|
||||
if (targets.every((t) => (t as HTMLInputElement).type === 'radio')) {
|
||||
target = theForm[key];
|
||||
} else {
|
||||
if (path.length === 0) {
|
||||
target = theForm.querySelector(`[name=${key}]`) as InputsType | undefined;
|
||||
}
|
||||
let fieldset: Element | null = theForm;
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
fieldset = fieldset?.querySelector(`fieldset[name=${path[i]}]`) ?? null;
|
||||
}
|
||||
if (fieldset) {
|
||||
target = fieldset.querySelector(`[name=${key}]`) as InputsType | undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (target && val === null) {
|
||||
if (['text', 'number'].includes(target.type)) {
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (target && typeof val === 'boolean') {
|
||||
if (target) {
|
||||
const element = target as HTMLInputElement;
|
||||
element.checked = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (target && val && val.constructor === Date) {
|
||||
target.value = dateFormat(val);
|
||||
}
|
||||
|
||||
if (target && typeof val !== 'object') {
|
||||
target.value = val.toString();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
// if (element?.[0]?.type.toLowerCase() === 'checkbox') {
|
||||
// const checkboxes: HTMLInputElement[] = Array.from(target.querySelectorAll(`[name=${key}]`));
|
||||
// checkboxes.forEach((checkbox) => {
|
||||
// if (checkbox.type.toLowerCase() === 'checkbox') {
|
||||
// checkbox.checked = val.includes(checkbox.value);
|
||||
// }
|
||||
// });
|
||||
// } else
|
||||
if (target && target.nodeName === 'SELECT') {
|
||||
const element = target as HTMLSelectElement;
|
||||
const options: HTMLOptionElement[] = Array.from(element.querySelectorAll('option'));
|
||||
options.forEach((option) => {
|
||||
option.selected = val.includes(option.value);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fillForm({ theForm, state: state[key], path: [...path, key] });
|
||||
});
|
||||
};
|
||||
|
||||
export default fillForm;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { InputsType, FillStateArgsType } from './types';
|
||||
import findPath from './_find-path';
|
||||
|
||||
const fillState = (args: FillStateArgsType) => {
|
||||
const { theForm, initialState, update, setFilledState, stateRef } = args;
|
||||
const filledState = JSON.parse(JSON.stringify(initialState));
|
||||
const nodeList = theForm.querySelectorAll('input, select, text-area');
|
||||
const elements = Array.from(nodeList) as InputsType[];
|
||||
elements.forEach((elem) => {
|
||||
const name = (elem as InputsType).name;
|
||||
if (!name || (elem as HTMLElement).nodeName === 'FIELDSET') return;
|
||||
|
||||
const path = findPath(elem as HTMLElement, theForm);
|
||||
let target = filledState;
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const prop = path[i]!;
|
||||
target[prop] = target[prop] || {};
|
||||
target = target[prop];
|
||||
}
|
||||
|
||||
//TODO account for unchecked checkboxes (false not null)
|
||||
target[name] = typeof target[name] !== 'undefined' ? target[name] : null;
|
||||
|
||||
update(filledState);
|
||||
|
||||
stateRef.current = filledState;
|
||||
setFilledState(true);
|
||||
});
|
||||
};
|
||||
|
||||
export default fillState;
|
||||
@@ -0,0 +1,16 @@
|
||||
const findPath = (elem: HTMLElement, form: HTMLFormElement | null, path: string[] = []) => {
|
||||
let parent = elem.parentNode as ParentNode | null;
|
||||
|
||||
while (parent && parent !== form) {
|
||||
if (parent.nodeName.toLowerCase() === 'fieldset') {
|
||||
const fieldset = parent as HTMLFieldSetElement;
|
||||
if (fieldset.name) {
|
||||
path.unshift(fieldset.name);
|
||||
}
|
||||
}
|
||||
parent = parent.parentNode;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
export default findPath;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useForm';
|
||||
@@ -0,0 +1,15 @@
|
||||
export type InputsType = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
|
||||
export type FillStateArgsType = {
|
||||
theForm: HTMLFormElement;
|
||||
initialState: Record<string, any>;
|
||||
update: (state: any | ((prevState: any) => any)) => void;
|
||||
setFilledState: (filled: boolean) => void;
|
||||
stateRef: { current: Record<string, any> };
|
||||
};
|
||||
|
||||
export type FillFormArgs = {
|
||||
theForm: HTMLFormElement;
|
||||
state: Record<string, any>;
|
||||
path?: string[];
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import fillForm from './_fill-form';
|
||||
import fillState from './_fill-state';
|
||||
import findPath from './_find-path';
|
||||
|
||||
export const useForm = <ST extends Record<string, any>>(
|
||||
initialState: Partial<ST> = {},
|
||||
validate?: (state: Partial<ST>) => boolean,
|
||||
) => {
|
||||
const [filledState, setFilledState] = useState<boolean>(false);
|
||||
const [state, setState] = useState<Partial<ST>>(initialState);
|
||||
const initializedRef = useRef(false);
|
||||
const stateRef = useRef<Partial<ST>>(initialState);
|
||||
const observerRef = useRef<MutationObserver | null>(null);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
const onReset = () => {
|
||||
stateRef.current = {} as ST;
|
||||
setState({} as ST);
|
||||
};
|
||||
const onSubmit = (ev: SubmitEvent) => {
|
||||
ev.preventDefault();
|
||||
setState(stateRef.current);
|
||||
};
|
||||
|
||||
const initialize = (theForm: HTMLFormElement) => {
|
||||
addEvents(theForm);
|
||||
fillState({ theForm, initialState, update, setFilledState, stateRef });
|
||||
fillForm({ theForm, state: stateRef.current });
|
||||
initializedRef.current = true;
|
||||
};
|
||||
|
||||
const destroy = (theForm: HTMLFormElement | null) => {
|
||||
initializedRef.current = false;
|
||||
removeEventListeners(theForm);
|
||||
setState(initialState);
|
||||
stateRef.current = state;
|
||||
};
|
||||
|
||||
const addEvents = (theForm: HTMLFormElement) => {
|
||||
theForm.addEventListener('input', onChange);
|
||||
theForm.addEventListener('change', onChange);
|
||||
theForm.addEventListener('reset', onReset);
|
||||
theForm.addEventListener('submit', onSubmit);
|
||||
};
|
||||
|
||||
const removeEventListeners = (theForm: HTMLFormElement | null) => {
|
||||
if (!theForm) return;
|
||||
theForm.removeEventListener('input', onChange);
|
||||
theForm.removeEventListener('change', onChange);
|
||||
theForm.removeEventListener('reset', onReset);
|
||||
theForm.removeEventListener('submit', onSubmit);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const formElement = formRef.current;
|
||||
if (formElement && !filledState) {
|
||||
initialize(formElement);
|
||||
}
|
||||
}, [formRef, filledState]);
|
||||
|
||||
// start everything
|
||||
useEffect(() => {
|
||||
const { current: formElement } = formRef;
|
||||
if (!formElement) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (formRef.current && !initializedRef.current) {
|
||||
initialize(formRef.current);
|
||||
}
|
||||
if (!formRef.current && initializedRef.current) {
|
||||
destroy(formRef.current);
|
||||
}
|
||||
});
|
||||
observerRef.current = observer;
|
||||
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
}, []);
|
||||
|
||||
const update: StateSetter<Partial<ST>> = (param) => {
|
||||
let newState;
|
||||
if (typeof param === 'function') {
|
||||
newState = param(stateRef.current);
|
||||
} else {
|
||||
newState = param;
|
||||
}
|
||||
fillForm({ theForm: formRef.current!, state: newState });
|
||||
stateRef.current = newState;
|
||||
setState(newState);
|
||||
};
|
||||
|
||||
const onChange = (ev: Event) => {
|
||||
const target = ev.target as HTMLInputElement | HTMLSelectElement;
|
||||
const { name, value, dataset = {} } = target;
|
||||
|
||||
if (!name) return;
|
||||
const type = dataset['type'] || target.type;
|
||||
const path = findPath(target, formRef.current);
|
||||
|
||||
const newState = { ...stateRef.current };
|
||||
let targetObj = newState as any;
|
||||
for (let prop of path) {
|
||||
targetObj[prop] = targetObj[prop] || {};
|
||||
targetObj = targetObj[prop];
|
||||
}
|
||||
|
||||
const isTrueFalse =
|
||||
target.type === 'radio' &&
|
||||
formRef.current?.[name]?.length === 2 &&
|
||||
Array.from(formRef.current[name] as NodeListOf<HTMLInputElement>).every((el: HTMLInputElement) =>
|
||||
['true', 'false'].includes(el.value),
|
||||
);
|
||||
|
||||
//Case for Checkbox:
|
||||
if (isTrueFalse) {
|
||||
targetObj[name] = value === 'true' ? true : value === 'false' ? false : null;
|
||||
} else if (target.type === 'checkbox') {
|
||||
const { checked } = target;
|
||||
const count = formRef.current?.[name].length;
|
||||
if (count > 1) {
|
||||
targetObj[name] = [...formRef.current?.[name]]?.filter((c) => c.checked).map((c) => c.value);
|
||||
} else {
|
||||
targetObj[name] = checked;
|
||||
}
|
||||
} else if (target.nodeName === 'SELECT' && target.multiple) {
|
||||
const options = Array.from(target.querySelectorAll('option'));
|
||||
targetObj[name] = options.filter((o: HTMLOptionElement) => o.selected).map((o: HTMLOptionElement) => o.value);
|
||||
} else {
|
||||
if (['number', 'range'].includes(type)) {
|
||||
if (value === '') {
|
||||
targetObj[name] = null;
|
||||
} else {
|
||||
targetObj[name] = +value;
|
||||
}
|
||||
} else {
|
||||
targetObj[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
stateRef.current = newState;
|
||||
setState(newState);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setState(initialState);
|
||||
formRef.current?.reset();
|
||||
};
|
||||
|
||||
const isValid = !!validate ? validate(state) : true;
|
||||
|
||||
return { state, formState: state, formRef, update, reset: clear, clear, isValid };
|
||||
};
|
||||
|
||||
export type UseFormHookType = ReturnType<typeof useForm>;
|
||||
|
||||
// const triggerChange = (element) => {
|
||||
// console.log('triggering on ', element);
|
||||
// const event = new Event('change');
|
||||
// element.dispatchEvent(event);
|
||||
// };
|
||||
@@ -0,0 +1 @@
|
||||
export * from './use-fullscreen';
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type UseFullscreenOptions = {
|
||||
ref?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export const useFullscreen = (options?: UseFullscreenOptions) => {
|
||||
const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement);
|
||||
|
||||
const getElement = useCallback(() => {
|
||||
if (options?.ref?.current) {
|
||||
return options.ref.current;
|
||||
}
|
||||
return document.documentElement;
|
||||
}, [options?.ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleChange);
|
||||
}, []);
|
||||
|
||||
const enterFullscreen = useCallback(async () => {
|
||||
const elem = getElement();
|
||||
if (!document.fullscreenElement && elem) {
|
||||
try {
|
||||
await elem.requestFullscreen();
|
||||
} catch (err) {
|
||||
console.error('Failed to enter fullscreen:', err);
|
||||
}
|
||||
}
|
||||
}, [getElement]);
|
||||
|
||||
const exitFullscreen = useCallback(async () => {
|
||||
if (document.fullscreenElement) {
|
||||
try {
|
||||
await document.exitFullscreen();
|
||||
} catch (err) {
|
||||
console.error('Failed to exit fullscreen:', err);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
if (document.fullscreenElement) {
|
||||
await exitFullscreen();
|
||||
} else {
|
||||
await enterFullscreen();
|
||||
}
|
||||
}, [enterFullscreen, exitFullscreen]);
|
||||
|
||||
return {
|
||||
isFullscreen,
|
||||
fullscreen: enterFullscreen,
|
||||
enterFullscreen,
|
||||
exitFullscreen,
|
||||
toggleFullscreen,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export const getFullscreenElement = () => document.fullscreenElement;
|
||||
|
||||
export const exitFullscreen = () => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
export const isFullscreen = () => !!document.fullscreenElement;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const useGlobal = <T>(key: string | string[], initialData: T | (() => T)) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const theKey = Array.isArray(key) ? ['USE_GLOBAL', ...key] : ['USE_GLOBAL', key];
|
||||
const theInitialData = typeof initialData === 'function' ? (initialData as () => T)() : initialData;
|
||||
|
||||
const { data } = useQuery<T>({
|
||||
queryKey: theKey,
|
||||
enabled: false,
|
||||
queryFn: () => theInitialData,
|
||||
initialData: theInitialData,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const setData: React.Dispatch<React.SetStateAction<T>> = (arg) => {
|
||||
const val = typeof arg === 'function' ? (arg as (arg0: T) => T)(data as T) : arg;
|
||||
queryClient.setQueryData(theKey, () => val);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
queryClient.removeQueries({ queryKey: theKey });
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: theKey });
|
||||
};
|
||||
|
||||
return [data!, setData, refresh, reset] as const;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { config } from 'config';
|
||||
|
||||
export const useImageLoader = (baseUrl: string = config.FILES_URL) => {
|
||||
const listContainer = useRef<HTMLDivElement>(null);
|
||||
const filesClient = useClient(baseUrl);
|
||||
|
||||
const loadImages = async (imageElems: HTMLImageElement[]) => {
|
||||
imageElems.forEach(async (img) => {
|
||||
const url = img.getAttribute('data-src');
|
||||
if (url) {
|
||||
const blob = await filesClient.getBlob(url);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
img.src = objectUrl;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
const imageElems: HTMLImageElement[] = [];
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'childList') {
|
||||
const addedNodes = Array.from(mutation.addedNodes);
|
||||
for (const node of addedNodes) {
|
||||
if (node instanceof HTMLElement) {
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
const images = Array.from(parent.querySelectorAll<HTMLImageElement>('img[data-src]'));
|
||||
imageElems.push(...images);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loadImages(imageElems);
|
||||
});
|
||||
|
||||
if (listContainer.current) {
|
||||
const imageElems = Array.from(listContainer.current.querySelectorAll<HTMLImageElement>('img[data-src]'));
|
||||
loadImages(imageElems);
|
||||
observer.observe(listContainer.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [listContainer.current]);
|
||||
|
||||
return { listContainer };
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean>(
|
||||
typeof window !== 'undefined' ? window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches : false,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
|
||||
const onChange = (event: MediaQueryListEvent) => {
|
||||
setIsMobile(event.matches);
|
||||
};
|
||||
|
||||
setIsMobile(mql.matches);
|
||||
|
||||
mql.addEventListener('change', onChange);
|
||||
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
export type UseIsMobileReturn = ReturnType<typeof useIsMobile>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useGlobal } from './useGlobal';
|
||||
|
||||
let isMounted = false;
|
||||
|
||||
export const useIsProduction = () => {
|
||||
const [isProduction, setIsProduction] = useGlobal<boolean>('IS_PRODUCTION', true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) {
|
||||
isMounted = true;
|
||||
const isProduction = window.location.protocol.includes('https');
|
||||
setIsProduction(isProduction);
|
||||
if (!isProduction) {
|
||||
document.title = 'Dev ' + document.title;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return isProduction;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useLocalStorageState<T>(key: string, initialValue: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const isClient = typeof window !== 'undefined';
|
||||
|
||||
const getInitialValue = (): T => {
|
||||
if (!isClient) return initialValue;
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored !== null) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return stored as unknown as T;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return initialValue;
|
||||
};
|
||||
|
||||
const [value, setValue] = useState<T>(getInitialValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [key, value]);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const useMounted = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted;
|
||||
};
|
||||
|
||||
export type UseMountedReturn = ReturnType<typeof useMounted>;
|
||||
@@ -0,0 +1,440 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Parameters for the usePhotoEditor hook.
|
||||
*/
|
||||
interface UsePhotoEditorParams {
|
||||
/**
|
||||
* The image file to be edited.
|
||||
*/
|
||||
file?: File;
|
||||
|
||||
/**
|
||||
* Initial brightness level (default: 100).
|
||||
*/
|
||||
defaultBrightness?: number;
|
||||
|
||||
/**
|
||||
* Initial contrast level (default: 100).
|
||||
*/
|
||||
defaultContrast?: number;
|
||||
|
||||
/**
|
||||
* Initial saturation level (default: 100).
|
||||
*/
|
||||
defaultSaturate?: number;
|
||||
|
||||
/**
|
||||
* Initial grayscale level (default: 0).
|
||||
*/
|
||||
defaultGrayscale?: number;
|
||||
|
||||
/**
|
||||
* Flip the image horizontally (default: false).
|
||||
*/
|
||||
defaultFlipHorizontal?: boolean;
|
||||
|
||||
/**
|
||||
* Flip the image vertically (default: false).
|
||||
*/
|
||||
defaultFlipVertical?: boolean;
|
||||
|
||||
/**
|
||||
* Initial zoom level (default: 1).
|
||||
*/
|
||||
defaultZoom?: number;
|
||||
|
||||
/**
|
||||
* Initial rotation angle in degrees (default: 0).
|
||||
*/
|
||||
defaultRotate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for handling photo editing within a canvas.
|
||||
*
|
||||
* @param {UsePhotoEditorParams} params - Configuration parameters for the hook.
|
||||
* @returns {Object} - Returns state and functions for managing image editing.
|
||||
*/
|
||||
export const usePhotoEditor = ({
|
||||
file,
|
||||
defaultBrightness = 100,
|
||||
defaultContrast = 100,
|
||||
defaultSaturate = 100,
|
||||
defaultGrayscale = 0,
|
||||
defaultFlipHorizontal = false,
|
||||
defaultFlipVertical = false,
|
||||
defaultZoom = 1,
|
||||
defaultRotate = 0,
|
||||
}: UsePhotoEditorParams) => {
|
||||
// Ref to the canvas element where the image will be drawn.
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
// Create the image object using a ref
|
||||
const imgRef = useRef(new Image());
|
||||
|
||||
// State to hold the source of the image.
|
||||
const [imageSrc, setImageSrc] = useState<string>('');
|
||||
|
||||
// State variables for various image transformations.
|
||||
const [brightness, setBrightness] = useState(defaultBrightness);
|
||||
const [contrast, setContrast] = useState(defaultContrast);
|
||||
const [saturate, setSaturate] = useState(defaultSaturate);
|
||||
const [grayscale, setGrayscale] = useState(defaultGrayscale);
|
||||
const [rotate, setRotate] = useState(defaultRotate);
|
||||
const [flipHorizontal, setFlipHorizontal] = useState(defaultFlipHorizontal);
|
||||
const [flipVertical, setFlipVertical] = useState(defaultFlipVertical);
|
||||
const [zoom, setZoom] = useState(defaultZoom);
|
||||
|
||||
// State variables for handling drag-and-drop panning.
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [panStart, setPanStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [offsetY, setOffsetY] = useState(0);
|
||||
|
||||
// Effect to prevent pinch zoom
|
||||
// useEffect(() => {
|
||||
// const preventDefault = (e: Event) => e.preventDefault();
|
||||
|
||||
// // Prevent zooming with touch gestures
|
||||
// document.addEventListener('gesturestart', preventDefault);
|
||||
// document.addEventListener('gesturechange', preventDefault);
|
||||
// document.addEventListener('gestureend', preventDefault);
|
||||
|
||||
// // Prevent zooming with touch events
|
||||
// const handleTouchEvent = (e: TouchEvent) => {
|
||||
// if (canvasRef.current && !canvasRef.current.contains(e.target as Node)) {
|
||||
// e.preventDefault();
|
||||
// }
|
||||
// };
|
||||
// document.addEventListener('touchstart', handleTouchEvent, { passive: false });
|
||||
// document.addEventListener('touchmove', handleTouchEvent, { passive: false });
|
||||
// document.addEventListener('touchend', handleTouchEvent, { passive: false });
|
||||
|
||||
// // Prevent zooming with mouse wheel outside the canvas
|
||||
// const handleWheelEvent = (e: WheelEvent) => {
|
||||
// if (canvasRef.current && !canvasRef.current.contains(e.target as Node)) {
|
||||
// e.preventDefault();
|
||||
// }
|
||||
// };
|
||||
// document.addEventListener('wheel', handleWheelEvent, { passive: false });
|
||||
|
||||
// return () => {
|
||||
// document.removeEventListener('gesturestart', preventDefault);
|
||||
// document.removeEventListener('gesturechange', preventDefault);
|
||||
// document.removeEventListener('gestureend', preventDefault);
|
||||
// document.removeEventListener('touchstart', handleTouchEvent);
|
||||
// document.removeEventListener('touchmove', handleTouchEvent);
|
||||
// document.removeEventListener('touchend', handleTouchEvent);
|
||||
// document.removeEventListener('wheel', handleWheelEvent);
|
||||
// };
|
||||
// }, [canvasRef]);
|
||||
|
||||
// Effect to update the image source when the file changes.
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const fileSrc = URL.createObjectURL(file);
|
||||
setImageSrc(fileSrc);
|
||||
|
||||
// Clean up the object URL when the component unmounts or file changes.
|
||||
return () => {
|
||||
URL.revokeObjectURL(fileSrc);
|
||||
};
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
// Effect to apply transformations and filters whenever relevant state changes.
|
||||
useEffect(() => {
|
||||
applyFilter();
|
||||
}, [
|
||||
file,
|
||||
imageSrc,
|
||||
rotate,
|
||||
flipHorizontal,
|
||||
flipVertical,
|
||||
zoom,
|
||||
brightness,
|
||||
contrast,
|
||||
saturate,
|
||||
grayscale,
|
||||
offsetX,
|
||||
offsetY,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Applies the selected filters and transformations to the image on the canvas.
|
||||
*/
|
||||
const applyFilter = () => {
|
||||
if (!imageSrc) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext('2d');
|
||||
|
||||
const imgElement = imgRef.current;
|
||||
imgRef.current.src = imageSrc;
|
||||
imgRef.current.onload = applyFilter;
|
||||
|
||||
imgElement.onload = () => {
|
||||
if (canvas && context) {
|
||||
const zoomedWidth = imgElement.width * zoom;
|
||||
const zoomedHeight = imgElement.height * zoom;
|
||||
const translateX = (imgElement.width - zoomedWidth) / 2;
|
||||
const translateY = (imgElement.height - zoomedHeight) / 2;
|
||||
|
||||
// Set canvas dimensions to match the image.
|
||||
canvas.width = imgElement.width;
|
||||
canvas.height = imgElement.height;
|
||||
|
||||
// Clear the canvas before drawing the updated image.
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Apply filters and transformations.
|
||||
context.filter = getFilterString();
|
||||
context.save();
|
||||
|
||||
if (rotate) {
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
context.translate(centerX, centerY);
|
||||
context.rotate((rotate * Math.PI) / 180);
|
||||
context.translate(-centerX, -centerY);
|
||||
}
|
||||
if (flipHorizontal) {
|
||||
context.translate(canvas.width, 0);
|
||||
context.scale(-1, 1);
|
||||
}
|
||||
if (flipVertical) {
|
||||
context.translate(0, canvas.height);
|
||||
context.scale(1, -1);
|
||||
}
|
||||
|
||||
context.translate(translateX + offsetX, translateY + offsetY);
|
||||
context.scale(zoom, zoom);
|
||||
context.drawImage(imgElement, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a file from the canvas content.
|
||||
* @returns {Promise<File | null>} A promise that resolves with the edited file or null if the canvas is not available.
|
||||
*/
|
||||
const generateEditedFile = (): Promise<File | null> => {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !file) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileExtension = (file.name.split('.').pop() || '').toLowerCase();
|
||||
let mimeType;
|
||||
switch (fileExtension) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
mimeType = 'image/jpeg';
|
||||
break;
|
||||
case 'png':
|
||||
mimeType = 'image/png';
|
||||
break;
|
||||
default:
|
||||
mimeType = 'image/png';
|
||||
}
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const newFile = new File([blob], file.name, { type: blob.type });
|
||||
resolve(newFile);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}, mimeType);
|
||||
});
|
||||
};
|
||||
|
||||
const getDataUrl = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas && file) {
|
||||
return canvas.toDataURL(file?.type);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas && file) {
|
||||
const link = document.createElement('a');
|
||||
link.download = file.name;
|
||||
link.href = canvas.toDataURL(file?.type);
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a string representing the current filter settings.
|
||||
*
|
||||
* @returns {string} - A CSS filter string.
|
||||
*/
|
||||
const getFilterString = (): string => {
|
||||
return `brightness(${brightness}%) contrast(${contrast}%) grayscale(${grayscale}%) saturate(${saturate}%)`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the zoom-in action.
|
||||
*/
|
||||
const handleZoomIn = () => {
|
||||
setZoom((prevZoom) => prevZoom + 0.05);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the zoom-out action.
|
||||
*/
|
||||
const handleZoomOut = () => {
|
||||
setZoom((prevZoom) => {
|
||||
const newZoom = prevZoom - 0.05;
|
||||
if (newZoom < 1) return 1;
|
||||
return newZoom;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the pointer down event for initiating drag-and-drop panning.
|
||||
*/
|
||||
const handlePointerDown = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
setIsDragging(true);
|
||||
const initialX = event.clientX - (flipHorizontal ? -offsetX : offsetX);
|
||||
const initialY = event.clientY - (flipVertical ? -offsetY : offsetY);
|
||||
setPanStart({ x: initialX, y: initialY });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the pointer move event for updating the image position during drag-and-drop panning.
|
||||
*/
|
||||
const handlePointerMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (isDragging && panStart) {
|
||||
event.preventDefault();
|
||||
|
||||
const offsetXDelta = event.clientX - panStart.x;
|
||||
const offsetYDelta = event.clientY - panStart.y;
|
||||
|
||||
setOffsetX(flipHorizontal ? -offsetXDelta : offsetXDelta);
|
||||
setOffsetY(flipVertical ? -offsetYDelta : offsetYDelta);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the pointer up event for ending the drag-and-drop panning.
|
||||
*/
|
||||
const handlePointerUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the wheel event for zooming in and out.
|
||||
*/
|
||||
const handleWheel = (event: React.WheelEvent<HTMLCanvasElement>) => {
|
||||
if (event.deltaY > 0) {
|
||||
handleZoomIn();
|
||||
} else {
|
||||
handleZoomOut();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets the filters and styles to its original state with the default settings.
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
setBrightness(defaultBrightness);
|
||||
setContrast(defaultContrast);
|
||||
setSaturate(defaultSaturate);
|
||||
setGrayscale(defaultGrayscale);
|
||||
setRotate(defaultRotate);
|
||||
setFlipHorizontal(defaultFlipHorizontal);
|
||||
setFlipVertical(defaultFlipVertical);
|
||||
setZoom(defaultZoom);
|
||||
setOffsetX(0);
|
||||
setOffsetY(0);
|
||||
setPanStart(null);
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// Expose the necessary state and handlers for external use.
|
||||
return {
|
||||
/** Reference to the canvas element. */
|
||||
canvasRef,
|
||||
/** Source URL of the image being edited. */
|
||||
imageSrc,
|
||||
/** Current brightness level. */
|
||||
brightness,
|
||||
/** Current contrast level. */
|
||||
contrast,
|
||||
/** Current saturation level. */
|
||||
saturate,
|
||||
/** Current grayscale level. */
|
||||
grayscale,
|
||||
/** Current rotation angle in degrees. */
|
||||
rotate,
|
||||
/** Flag indicating if the image is flipped horizontally. */
|
||||
flipHorizontal,
|
||||
/** Flag indicating if the image is flipped vertically. */
|
||||
flipVertical,
|
||||
/** Current zoom level. */
|
||||
zoom,
|
||||
/** Flag indicating if the image is being dragged. */
|
||||
isDragging,
|
||||
/** Starting coordinates for panning. */
|
||||
panStart,
|
||||
/** Current horizontal offset for panning. */
|
||||
offsetX,
|
||||
/** Current vertical offset for panning. */
|
||||
offsetY,
|
||||
/** Function to set the brightness level. */
|
||||
setBrightness,
|
||||
/** Function to set the contrast level. */
|
||||
setContrast,
|
||||
/** Function to set the saturation level. */
|
||||
setSaturate,
|
||||
/** Function to set the grayscale level. */
|
||||
setGrayscale,
|
||||
/** Function to set the rotation angle. */
|
||||
setRotate,
|
||||
/** Function to set the horizontal flip state. */
|
||||
setFlipHorizontal,
|
||||
/** Function to set the vertical flip state. */
|
||||
setFlipVertical,
|
||||
/** Function to set the zoom level. */
|
||||
setZoom,
|
||||
/** Function to set the dragging state. */
|
||||
setIsDragging,
|
||||
/** Function to set the starting coordinates for panning. */
|
||||
setPanStart,
|
||||
/** Function to set the horizontal offset for panning. */
|
||||
setOffsetX,
|
||||
/** Function to set the vertical offset for panning. */
|
||||
setOffsetY,
|
||||
/** Function to zoom in. */
|
||||
handleZoomIn,
|
||||
/** Function to zoom out. */
|
||||
handleZoomOut,
|
||||
/** Function to handle pointer down events. */
|
||||
handlePointerDown,
|
||||
/** Function to handle pointer up events. */
|
||||
handlePointerUp,
|
||||
/** Function to handle pointer move events. */
|
||||
handlePointerMove,
|
||||
/** Function to handle wheel events for zooming. */
|
||||
handleWheel,
|
||||
/** Function to download the edited image. */
|
||||
downloadImage,
|
||||
/** Function to generate the edited image file. */
|
||||
generateEditedFile,
|
||||
/** Function to reset filters and styles to default. */
|
||||
resetFilters,
|
||||
/** Function to apply filters and transformations. */
|
||||
applyFilter,
|
||||
getDataUrl,
|
||||
};
|
||||
};
|
||||
|
||||
export type UsePhotoEditorType = ReturnType<typeof usePhotoEditor>;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
export const usePopover = () => {
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
const popover = document.querySelector('.MuiPopover-paper');
|
||||
if (!popover) return;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
popover.addEventListener('mouseenter', () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
popover.addEventListener('mouseleave', () => {
|
||||
timeoutId = setTimeout(() => setOpen(false), 200);
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return { ref, open, setOpen };
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './useQueryState';
|
||||
export * from './useGlobalQueryString';
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const useGlobalQueryString = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [queryString, setQueryString] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const updateQueryString = () => {
|
||||
const globalCache = queryClient
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.filter((cache) => cache.queryKey[1] === 'QUERY_STATE')
|
||||
.map((cache) => ({
|
||||
key: cache.queryKey[2] as string,
|
||||
data: cache.state.data as string | number | null,
|
||||
}));
|
||||
|
||||
const globalParams = globalCache.filter((cache) => cache.data !== null);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
globalParams.forEach((cache) => {
|
||||
if (cache.data !== null) {
|
||||
searchParams.append(cache.key, cache.data.toString());
|
||||
}
|
||||
});
|
||||
setQueryString(searchParams.toString());
|
||||
};
|
||||
|
||||
updateQueryString();
|
||||
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.query.queryKey[1] === 'QUERY_STATE') {
|
||||
updateQueryString();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [queryClient]);
|
||||
|
||||
return queryString;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from '../useGlobal';
|
||||
import { useDebounce } from '../useDebounce';
|
||||
|
||||
export const useQueryState = <T extends string | number | null>(key: string, defaultValue?: T, isGlobal = false) => {
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [currentLocation, setCurrentLocation] = useGlobal<typeof location>('LOCATION', location);
|
||||
|
||||
const parseValue = (val: string | null): T => {
|
||||
if (val === null) return defaultValue as T;
|
||||
if (typeof defaultValue === 'number') return Number(val) as T;
|
||||
return val as T;
|
||||
};
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (location && currentLocation) {
|
||||
if (location.pathname !== currentLocation.pathname) {
|
||||
const globalCache = queryClient
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.filter((cache) => cache.queryKey[1] === 'QUERY_STATE')
|
||||
.map((cache) => ({
|
||||
key: cache.queryKey[2],
|
||||
data: cache.state.data,
|
||||
}));
|
||||
|
||||
const globalQuery = globalCache
|
||||
.filter((cache) => cache.data !== null)
|
||||
.map((cache) => `${cache.key}=${cache.data}`)
|
||||
.toSorted()
|
||||
.join('&');
|
||||
|
||||
const url = `${window.location.pathname}?${globalQuery}`;
|
||||
navigate(url);
|
||||
|
||||
setCurrentLocation(location);
|
||||
}
|
||||
}
|
||||
},
|
||||
100,
|
||||
[location],
|
||||
);
|
||||
|
||||
const [value, setValue] = isGlobal
|
||||
? useGlobal(['QUERY_STATE', key], () => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
return parseValue(searchParams.get(key));
|
||||
})
|
||||
: useState<T>(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
return parseValue(searchParams.get(key));
|
||||
});
|
||||
|
||||
const setTheValue: React.Dispatch<React.SetStateAction<T>> = (arg) => {
|
||||
const newValue = typeof arg === 'function' ? arg(value as T) : arg;
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (newValue !== null && newValue !== undefined) {
|
||||
searchParams.set(key, String(newValue));
|
||||
} else {
|
||||
searchParams.delete(key);
|
||||
}
|
||||
|
||||
const url = `${window.location.pathname}?${searchParams.toString()}${window.location.hash}`;
|
||||
window.history.replaceState({}, '', url);
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
const reset = () => setTheValue(defaultValue as T);
|
||||
const clear = () => setTheValue(null as T);
|
||||
|
||||
return [value as T, setTheValue, reset, clear] as const;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
//@ts-nocheck
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const useTimeout = (callback, delay) => {
|
||||
const callbackRef = useRef(callback);
|
||||
const timeoutRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
const set = useCallback(() => {
|
||||
timeoutRef.current = setTimeout(() => callbackRef.current(), delay);
|
||||
}, [delay]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
timeoutRef.current && clearTimeout(timeoutRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
set();
|
||||
return clear;
|
||||
}, [delay, set, clear]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clear();
|
||||
set();
|
||||
}, [clear, set]);
|
||||
|
||||
return { reset, clear };
|
||||
};
|
||||
|
||||
export default useTimeout;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
export function useTimer() {
|
||||
const [timeValue, setTimeValue] = useState(0);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const runningRef = useRef(false);
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
if (runningRef.current) return;
|
||||
runningRef.current = true;
|
||||
startTimeRef.current = performance.now();
|
||||
|
||||
const tick = () => {
|
||||
if (!runningRef.current) return;
|
||||
const now = performance.now();
|
||||
const elapsed = now - (startTimeRef.current ?? now);
|
||||
setTimeValue(elapsed);
|
||||
frameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
frameRef.current = requestAnimationFrame(tick);
|
||||
}, []);
|
||||
|
||||
const stopTimer = useCallback(() => {
|
||||
runningRef.current = false;
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetTimer = useCallback(() => {
|
||||
stopTimer();
|
||||
setTimeValue(0);
|
||||
startTimeRef.current = null;
|
||||
}, [stopTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { timeValue, startTimer, stopTimer, resetTimer };
|
||||
}
|
||||
|
||||
export type UseTimerReturn = ReturnType<typeof useTimer>;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { config } from 'config';
|
||||
import type { WebSocketMessage } from './types';
|
||||
|
||||
type MessageHandler = (data: WebSocketMessage) => void;
|
||||
|
||||
export const useWebsockets = (onMessage?: MessageHandler) => {
|
||||
const [isOnline, setOnline] = useState(false);
|
||||
const [userCount, setUserCount] = useState(0);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const retryRef = useRef(0);
|
||||
const retryTimeoutRef = useRef<number | null>(null);
|
||||
const isCleaningUpRef = useRef(false);
|
||||
|
||||
const connectSocket = useCallback(() => {
|
||||
if (isCleaningUpRef.current) return;
|
||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const socket = new WebSocket(config.WS_URL);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
setOnline(true);
|
||||
retryRef.current = 0;
|
||||
console.log('🟢 Connected to', config.WS_URL);
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
try {
|
||||
let data: WebSocketMessage;
|
||||
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const text = new TextDecoder().decode(event.data);
|
||||
data = JSON.parse(text) as WebSocketMessage;
|
||||
} else if (typeof event.data === 'string') {
|
||||
data = JSON.parse(event.data) as WebSocketMessage;
|
||||
} else {
|
||||
console.warn('⚠️ Unknown WS message type:', typeof event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'type' in data &&
|
||||
data.type === 'users' &&
|
||||
'count' in data &&
|
||||
typeof data.count === 'number'
|
||||
) {
|
||||
console.log('👥 Users online:', data.count);
|
||||
setUserCount(data.count);
|
||||
return;
|
||||
}
|
||||
|
||||
onMessage?.(data);
|
||||
} catch (err) {
|
||||
console.warn('⚠️ Failed to parse WS message:', event.data, err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (isCleaningUpRef.current) return;
|
||||
|
||||
setOnline(false);
|
||||
console.log('🔴 Disconnected');
|
||||
|
||||
const retry = (retryRef.current ?? 0) + 1;
|
||||
retryRef.current = retry;
|
||||
const delay = Math.min(5000, 300 * retry);
|
||||
retryTimeoutRef.current = window.setTimeout(connectSocket, delay);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (err) => {
|
||||
console.error('⚠️ WebSocket error', err);
|
||||
socket.close();
|
||||
});
|
||||
}, [onMessage]);
|
||||
|
||||
const send = useCallback((data: string | Record<string, unknown>) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(typeof data === 'string' ? data : JSON.stringify(data));
|
||||
} else {
|
||||
console.warn('Cannot send message, socket not ready');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
isCleaningUpRef.current = false;
|
||||
connectSocket();
|
||||
|
||||
return () => {
|
||||
isCleaningUpRef.current = true;
|
||||
|
||||
if (retryTimeoutRef.current !== null) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connectSocket]);
|
||||
|
||||
return { isOnline, userCount, send };
|
||||
};
|
||||
|
||||
export type UseWebsocketsReturn = ReturnType<typeof useWebsockets>;
|
||||
Reference in New Issue
Block a user