first commit

This commit is contained in:
2026-02-04 23:23:42 +07:00
commit ee28ea1a2d
202 changed files with 34797 additions and 0 deletions

View 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;
}

View 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];
}

View 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,
};
}

View 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();
}, []);
}

View 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');
}, []);
}

View 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,
);
}

View 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,
};
};