first commit
This commit is contained in:
106
laravel/resources/js/hooks/use-appearance.tsx
Normal file
106
laravel/resources/js/hooks/use-appearance.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
export type ResolvedAppearance = 'light' | 'dark';
|
||||
export type Appearance = ResolvedAppearance | 'system';
|
||||
|
||||
export type UseAppearanceReturn = {
|
||||
readonly appearance: Appearance;
|
||||
readonly resolvedAppearance: ResolvedAppearance;
|
||||
readonly updateAppearance: (mode: Appearance) => void;
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let currentAppearance: Appearance = 'system';
|
||||
|
||||
const prefersDark = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const setCookie = (name: string, value: string, days = 365): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
};
|
||||
|
||||
const getStoredAppearance = (): Appearance => {
|
||||
if (typeof window === 'undefined') return 'system';
|
||||
|
||||
return (localStorage.getItem('appearance') as Appearance) || 'system';
|
||||
};
|
||||
|
||||
const isDarkMode = (appearance: Appearance): boolean => {
|
||||
return appearance === 'dark' || (appearance === 'system' && prefersDark());
|
||||
};
|
||||
|
||||
const applyTheme = (appearance: Appearance): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const isDark = isDarkMode(appearance);
|
||||
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const subscribe = (callback: () => void) => {
|
||||
listeners.add(callback);
|
||||
|
||||
return () => listeners.delete(callback);
|
||||
};
|
||||
|
||||
const notify = (): void => listeners.forEach((listener) => listener());
|
||||
|
||||
const mediaQuery = (): MediaQueryList | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)');
|
||||
};
|
||||
|
||||
const handleSystemThemeChange = (): void => {
|
||||
applyTheme(currentAppearance);
|
||||
notify();
|
||||
};
|
||||
|
||||
export function initializeTheme(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (!localStorage.getItem('appearance')) {
|
||||
localStorage.setItem('appearance', 'system');
|
||||
setCookie('appearance', 'system');
|
||||
}
|
||||
|
||||
currentAppearance = getStoredAppearance();
|
||||
applyTheme(currentAppearance);
|
||||
|
||||
// Set up system theme change listener
|
||||
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
|
||||
}
|
||||
|
||||
export function useAppearance(): UseAppearanceReturn {
|
||||
const appearance: Appearance = useSyncExternalStore(
|
||||
subscribe,
|
||||
() => currentAppearance,
|
||||
() => 'system',
|
||||
);
|
||||
|
||||
const resolvedAppearance: ResolvedAppearance = useMemo(
|
||||
() => (isDarkMode(appearance) ? 'dark' : 'light'),
|
||||
[appearance],
|
||||
);
|
||||
|
||||
const updateAppearance = useCallback((mode: Appearance): void => {
|
||||
currentAppearance = mode;
|
||||
|
||||
// Store in localStorage for client-side persistence...
|
||||
localStorage.setItem('appearance', mode);
|
||||
|
||||
// Store in cookie for SSR...
|
||||
setCookie('appearance', mode);
|
||||
|
||||
applyTheme(mode);
|
||||
notify();
|
||||
}, []);
|
||||
|
||||
return { appearance, resolvedAppearance, updateAppearance } as const;
|
||||
}
|
||||
32
laravel/resources/js/hooks/use-clipboard.ts
Normal file
32
laravel/resources/js/hooks/use-clipboard.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Credit: https://usehooks-ts.com/
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export type CopiedValue = string | null;
|
||||
export type CopyFn = (text: string) => Promise<boolean>;
|
||||
export type UseClipboardReturn = [CopiedValue, CopyFn];
|
||||
|
||||
export function useClipboard(): UseClipboardReturn {
|
||||
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
|
||||
|
||||
const copy: CopyFn = useCallback(async (text) => {
|
||||
if (!navigator?.clipboard) {
|
||||
console.warn('Clipboard not supported');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedText(text);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Copy failed', error);
|
||||
setCopiedText(null);
|
||||
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [copiedText, copy];
|
||||
}
|
||||
58
laravel/resources/js/hooks/use-current-url.ts
Normal file
58
laravel/resources/js/hooks/use-current-url.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { InertiaLinkProps } from '@inertiajs/react';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
|
||||
export type IsCurrentUrlFn = (
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
currentUrl?: string,
|
||||
) => boolean;
|
||||
|
||||
export type WhenCurrentUrlFn = <TIfTrue, TIfFalse = null>(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
ifTrue: TIfTrue,
|
||||
ifFalse?: TIfFalse,
|
||||
) => TIfTrue | TIfFalse;
|
||||
|
||||
export type UseCurrentUrlReturn = {
|
||||
currentUrl: string;
|
||||
isCurrentUrl: IsCurrentUrlFn;
|
||||
whenCurrentUrl: WhenCurrentUrlFn;
|
||||
};
|
||||
|
||||
export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
const page = usePage();
|
||||
const currentUrlPath = new URL(page.url, window?.location.origin).pathname;
|
||||
|
||||
const isCurrentUrl: IsCurrentUrlFn = (
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
currentUrl?: string,
|
||||
) => {
|
||||
const urlToCompare = currentUrl ?? currentUrlPath;
|
||||
const urlString = toUrl(urlToCheck);
|
||||
|
||||
if (!urlString.startsWith('http')) {
|
||||
return urlString === urlToCompare;
|
||||
}
|
||||
|
||||
try {
|
||||
const absoluteUrl = new URL(urlString);
|
||||
return absoluteUrl.pathname === urlToCompare;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const whenCurrentUrl: WhenCurrentUrlFn = <TIfTrue, TIfFalse = null>(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
ifTrue: TIfTrue,
|
||||
ifFalse: TIfFalse = null as TIfFalse,
|
||||
): TIfTrue | TIfFalse => {
|
||||
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
|
||||
};
|
||||
|
||||
return {
|
||||
currentUrl: currentUrlPath,
|
||||
isCurrentUrl,
|
||||
whenCurrentUrl,
|
||||
};
|
||||
}
|
||||
17
laravel/resources/js/hooks/use-initials.tsx
Normal file
17
laravel/resources/js/hooks/use-initials.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export type GetInitialsFn = (fullName: string) => string;
|
||||
|
||||
export function useInitials(): GetInitialsFn {
|
||||
return useCallback((fullName: string): string => {
|
||||
const names = fullName.trim().split(' ');
|
||||
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return names[0].charAt(0).toUpperCase();
|
||||
|
||||
const firstInitial = names[0].charAt(0);
|
||||
const lastInitial = names[names.length - 1].charAt(0);
|
||||
|
||||
return `${firstInitial}${lastInitial}`.toUpperCase();
|
||||
}, []);
|
||||
}
|
||||
10
laravel/resources/js/hooks/use-mobile-navigation.ts
Normal file
10
laravel/resources/js/hooks/use-mobile-navigation.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export type CleanupFn = () => void;
|
||||
|
||||
export function useMobileNavigation(): CleanupFn {
|
||||
return useCallback(() => {
|
||||
// Remove pointer-events style from body...
|
||||
document.body.style.removeProperty('pointer-events');
|
||||
}, []);
|
||||
}
|
||||
36
laravel/resources/js/hooks/use-mobile.tsx
Normal file
36
laravel/resources/js/hooks/use-mobile.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
const mql =
|
||||
typeof window === 'undefined'
|
||||
? undefined
|
||||
: window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
|
||||
function mediaQueryListener(callback: (event: MediaQueryListEvent) => void) {
|
||||
if (!mql) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
mql.addEventListener('change', callback);
|
||||
|
||||
return () => {
|
||||
mql.removeEventListener('change', callback);
|
||||
};
|
||||
}
|
||||
|
||||
function isSmallerThanBreakpoint(): boolean {
|
||||
return mql?.matches ?? false;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useIsMobile(): boolean {
|
||||
return useSyncExternalStore(
|
||||
mediaQueryListener,
|
||||
isSmallerThanBreakpoint,
|
||||
getServerSnapshot,
|
||||
);
|
||||
}
|
||||
110
laravel/resources/js/hooks/use-two-factor-auth.ts
Normal file
110
laravel/resources/js/hooks/use-two-factor-auth.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||
import type { TwoFactorSecretKey, TwoFactorSetupData } from '@/types';
|
||||
|
||||
export type UseTwoFactorAuthReturn = {
|
||||
qrCodeSvg: string | null;
|
||||
manualSetupKey: string | null;
|
||||
recoveryCodesList: string[];
|
||||
hasSetupData: boolean;
|
||||
errors: string[];
|
||||
clearErrors: () => void;
|
||||
clearSetupData: () => void;
|
||||
fetchQrCode: () => Promise<void>;
|
||||
fetchSetupKey: () => Promise<void>;
|
||||
fetchSetupData: () => Promise<void>;
|
||||
fetchRecoveryCodes: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const OTP_MAX_LENGTH = 6;
|
||||
|
||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
|
||||
const [qrCodeSvg, setQrCodeSvg] = useState<string | null>(null);
|
||||
const [manualSetupKey, setManualSetupKey] = useState<string | null>(null);
|
||||
const [recoveryCodesList, setRecoveryCodesList] = useState<string[]>([]);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
const hasSetupData = useMemo<boolean>(
|
||||
() => qrCodeSvg !== null && manualSetupKey !== null,
|
||||
[qrCodeSvg, manualSetupKey],
|
||||
);
|
||||
|
||||
const fetchQrCode = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const { svg } = await fetchJson<TwoFactorSetupData>(qrCode.url());
|
||||
setQrCodeSvg(svg);
|
||||
} catch {
|
||||
setErrors((prev) => [...prev, 'Failed to fetch QR code']);
|
||||
setQrCodeSvg(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSetupKey = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const { secretKey: key } = await fetchJson<TwoFactorSecretKey>(
|
||||
secretKey.url(),
|
||||
);
|
||||
setManualSetupKey(key);
|
||||
} catch {
|
||||
setErrors((prev) => [...prev, 'Failed to fetch a setup key']);
|
||||
setManualSetupKey(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearErrors = useCallback((): void => {
|
||||
setErrors([]);
|
||||
}, []);
|
||||
|
||||
const clearSetupData = useCallback((): void => {
|
||||
setManualSetupKey(null);
|
||||
setQrCodeSvg(null);
|
||||
clearErrors();
|
||||
}, [clearErrors]);
|
||||
|
||||
const fetchRecoveryCodes = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
const codes = await fetchJson<string[]>(recoveryCodes.url());
|
||||
setRecoveryCodesList(codes);
|
||||
} catch {
|
||||
setErrors((prev) => [...prev, 'Failed to fetch recovery codes']);
|
||||
setRecoveryCodesList([]);
|
||||
}
|
||||
}, [clearErrors]);
|
||||
|
||||
const fetchSetupData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
||||
} catch {
|
||||
setQrCodeSvg(null);
|
||||
setManualSetupKey(null);
|
||||
}
|
||||
}, [clearErrors, fetchQrCode, fetchSetupKey]);
|
||||
|
||||
return {
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
recoveryCodesList,
|
||||
hasSetupData,
|
||||
errors,
|
||||
clearErrors,
|
||||
clearSetupData,
|
||||
fetchQrCode,
|
||||
fetchSetupKey,
|
||||
fetchSetupData,
|
||||
fetchRecoveryCodes,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user