This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
+19
View File
@@ -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:*"
}
}
+23
View File
@@ -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';
+21
View File
@@ -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';
+13
View File
@@ -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 };
+161
View File
@@ -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 };
};
+176
View File
@@ -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);
};
+8
View File
@@ -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';
+15
View File
@@ -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[];
};
+168
View File
@@ -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;
+31
View File
@@ -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 };
};
+27
View File
@@ -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];
}
+13
View File
@@ -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>;
+440
View File
@@ -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>;
+24
View File
@@ -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;
};
+33
View File
@@ -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;
+48
View File
@@ -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>;
+111
View File
@@ -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>;