diff --git a/.drone.yml b/.drone.yml index 80481f2..6bd5743 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4,34 +4,34 @@ type: docker name: web-core-ci trigger: - branch: - - main - - develop - event: - - push - - pull_request + branch: + - main + - develop + event: + - push + - pull_request steps: - - name: install - image: node:22 - commands: - - corepack enable - - corepack prepare yarn@1.22.22 --activate - - yarn install --frozen-lockfile + - name: install + image: node:22 + commands: + - corepack enable + - corepack prepare yarn@1.22.22 --activate + - yarn install --frozen-lockfile - - name: lint - image: node:22 - commands: - - corepack enable - - corepack prepare yarn@1.22.22 --activate - - yarn lint + - name: lint + image: node:22 + commands: + - corepack enable + - corepack prepare yarn@1.22.22 --activate + - yarn lint - - name: build - image: node:22 - commands: - - corepack enable - - corepack prepare yarn@1.22.22 --activate - - yarn build + - name: build + image: node:22 + commands: + - corepack enable + - corepack prepare yarn@1.22.22 --activate + - yarn build --- kind: pipeline @@ -39,22 +39,22 @@ type: docker name: web-core-publish trigger: - branch: - - main - event: - - promote - target: - - production + branch: + - main + event: + - promote + target: + - production steps: - - name: publish-npm - image: node:22 - environment: - NEXUS_NPM_TOKEN: - from_secret: nexus_npm_token - commands: - - corepack enable - - corepack prepare yarn@1.22.22 --activate - - yarn install --frozen-lockfile - - npm config set //nexus.beatrice.wtf/repository/npm-hosted/:_authToken "$NEXUS_NPM_TOKEN" - - yarn publish:nexus + - name: publish-npm + image: node:22 + environment: + NEXUS_NPM_TOKEN: + from_secret: nexus_npm_token + commands: + - corepack enable + - corepack prepare yarn@1.22.22 --activate + - yarn install --frozen-lockfile + - npm config set //nexus.beatrice.wtf/repository/npm-hosted/:_authToken "$NEXUS_NPM_TOKEN" + - yarn publish:nexus diff --git a/.prettierrc b/.prettierrc index 27411b6..5a93724 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,7 @@ { - "$schema": "https://json.schemastore.org/prettierrc", - "singleQuote": true, - "semi": true, - "printWidth": 100, - "tabWidth": 2 + "$schema": "https://json.schemastore.org/prettierrc", + "singleQuote": true, + "semi": true, + "printWidth": 100, + "tabWidth": 4 } diff --git a/eslint.config.mjs b/eslint.config.mjs index c44915d..f70af4a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,47 +5,47 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { - ignores: [ - 'dist', - 'coverage', - 'node_modules', - '*.config.cjs', - 'vite.config.js', - 'vite.config.d.ts', - ], - }, - js.configs.recommended, - ...tseslint.configs.recommended, - { - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - globals: { - ...globals.browser, - ...globals.node, - }, + { + ignores: [ + 'dist', + 'coverage', + 'node_modules', + '*.config.cjs', + 'vite.config.js', + 'vite.config.d.ts', + ], }, - rules: { - 'no-empty': ['error', { allowEmptyCatch: true }], + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + 'no-empty': ['error', { allowEmptyCatch: true }], + }, }, - }, - { - files: ['src/**/*.{ts,tsx}'], - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, + { + files: ['src/**/*.{ts,tsx}'], + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + { + files: ['src/contexts/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, }, - }, - { - files: ['src/contexts/**/*.{ts,tsx}'], - rules: { - 'react-refresh/only-export-components': 'off', - }, - }, ); diff --git a/package.json b/package.json index 87201a2..6f45efe 100644 --- a/package.json +++ b/package.json @@ -1,51 +1,51 @@ { - "name": "@panic/web-core", - "version": "0.1.3", - "license": "AGPL-3.0-only", - "description": "Core auth and utilities for panic.haus web applications", - "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "name": "@panic/web-core", + "version": "0.1.3", + "license": "AGPL-3.0-only", + "description": "Core auth and utilities for panic.haus web applications", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "yarn clean && vite build && tsc -p tsconfig.build.json", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --write", + "format:check": "prettier . --check", + "prepublishOnly": "yarn build", + "publish:nexus": "npm publish --registry ${NEXUS_NPM_REGISTRY:-https://nexus.beatrice.wtf/repository/npm-hosted/}" + }, + "publishConfig": { + "registry": "https://nexus.beatrice.wtf/repository/npm-hosted/", + "access": "restricted" + }, + "peerDependencies": { + "react": "^19.0.0" + }, + "devDependencies": { + "@eslint/js": "^9", + "@types/react": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "eslint": "^9", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.1", + "globals": "^17.3.0", + "prettier": "^3.8.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.6.2", + "typescript-eslint": "^8.56.0", + "vite": "^7.0.0" } - }, - "files": [ - "dist" - ], - "scripts": { - "clean": "rm -rf dist", - "build": "yarn clean && vite build && tsc -p tsconfig.build.json", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "format": "prettier . --write", - "format:check": "prettier . --check", - "prepublishOnly": "yarn build", - "publish:nexus": "npm publish --registry ${NEXUS_NPM_REGISTRY:-https://nexus.beatrice.wtf/repository/npm-hosted/}" - }, - "publishConfig": { - "registry": "https://nexus.beatrice.wtf/repository/npm-hosted/", - "access": "restricted" - }, - "peerDependencies": { - "react": "^19.0.0" - }, - "devDependencies": { - "@eslint/js": "^9", - "@types/react": "^19.0.0", - "@vitejs/plugin-react": "^5.0.0", - "eslint": "^9", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.1", - "globals": "^17.3.0", - "prettier": "^3.8.1", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "typescript": "^5.6.2", - "typescript-eslint": "^8.56.0", - "vite": "^7.0.0" - } } diff --git a/renovate.json b/renovate.json index 7190a60..9775346 100644 --- a/renovate.json +++ b/renovate.json @@ -1,3 +1,3 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json" + "$schema": "https://docs.renovatebot.com/renovate-schema.json" } diff --git a/src/api/createApiClient.ts b/src/api/createApiClient.ts index 4cf691c..a0f82ed 100644 --- a/src/api/createApiClient.ts +++ b/src/api/createApiClient.ts @@ -1,134 +1,134 @@ export type RequestOptions = { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; - token?: string | null; - body?: unknown; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + token?: string | null; + body?: unknown; }; export type ResolveErrorInput = { - code?: string; - status?: number; - fallbackMessage?: string; + code?: string; + status?: number; + fallbackMessage?: string; }; export type CreateApiClientConfig = { - baseUrl: string; - resolveError?: (input: ResolveErrorInput) => string; - inferErrorCodeFromStatus?: (status?: number | null) => string | undefined; - fetchImpl?: typeof fetch; + baseUrl: string; + resolveError?: (input: ResolveErrorInput) => string; + inferErrorCodeFromStatus?: (status?: number | null) => string | undefined; + fetchImpl?: typeof fetch; }; export class ApiError extends Error { - status: number; - code?: string; - requestId?: string; - details?: unknown; - rawMessage?: string; - - constructor({ - message, - status, - code, - requestId, - details, - rawMessage, - }: { - message: string; status: number; code?: string; requestId?: string; details?: unknown; rawMessage?: string; - }) { - super(message); - this.name = 'ApiError'; - this.status = status; - this.code = code; - this.requestId = requestId; - this.details = details; - this.rawMessage = rawMessage; - } + + constructor({ + message, + status, + code, + requestId, + details, + rawMessage, + }: { + message: string; + status: number; + code?: string; + requestId?: string; + details?: unknown; + rawMessage?: string; + }) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.requestId = requestId; + this.details = details; + this.rawMessage = rawMessage; + } } function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value != null; + return typeof value === 'object' && value != null; } function parseErrorPayload(data: unknown) { - if (!isRecord(data)) { - return { - code: undefined as string | undefined, - rawMessage: undefined as string | undefined, - requestId: undefined as string | undefined, - details: undefined as unknown, - }; - } + if (!isRecord(data)) { + return { + code: undefined as string | undefined, + rawMessage: undefined as string | undefined, + requestId: undefined as string | undefined, + details: undefined as unknown, + }; + } - const code = typeof data.code === 'string' ? data.code : undefined; - const rawMessage = typeof data.error === 'string' ? data.error : undefined; - const requestId = typeof data.requestId === 'string' ? data.requestId : undefined; - const details = data.details; + const code = typeof data.code === 'string' ? data.code : undefined; + const rawMessage = typeof data.error === 'string' ? data.error : undefined; + const requestId = typeof data.requestId === 'string' ? data.requestId : undefined; + const details = data.details; - return { code, rawMessage, requestId, details }; + return { code, rawMessage, requestId, details }; } function defaultResolveError({ status, fallbackMessage }: ResolveErrorInput): string { - if (fallbackMessage) { - return fallbackMessage; - } + if (fallbackMessage) { + return fallbackMessage; + } - if (status != null) { - return `Request failed (${status}).`; - } + if (status != null) { + return `Request failed (${status}).`; + } - return 'Request failed. Please try again.'; + return 'Request failed. Please try again.'; } export function createApiClient(config: CreateApiClientConfig) { - const { - baseUrl, - resolveError = defaultResolveError, - inferErrorCodeFromStatus, - fetchImpl, - } = config; + const { + baseUrl, + resolveError = defaultResolveError, + inferErrorCodeFromStatus, + fetchImpl, + } = config; - async function request(path: string, options: RequestOptions = {}): Promise { - const { method = 'GET', token, body } = options; - const runFetch = fetchImpl ?? fetch; + async function request(path: string, options: RequestOptions = {}): Promise { + const { method = 'GET', token, body } = options; + const runFetch = fetchImpl ?? fetch; - const response = await runFetch(`${baseUrl}${path}`, { - method, - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: body ? JSON.stringify(body) : undefined, - }); + const response = await runFetch(`${baseUrl}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); - const data = await response.json().catch(() => null); + const data = await response.json().catch(() => null); - if (!response.ok) { - const parsed = parseErrorPayload(data); - const code = parsed.code ?? inferErrorCodeFromStatus?.(response.status); - const message = resolveError({ - code, - status: response.status, - fallbackMessage: parsed.rawMessage, - }); + if (!response.ok) { + const parsed = parseErrorPayload(data); + const code = parsed.code ?? inferErrorCodeFromStatus?.(response.status); + const message = resolveError({ + code, + status: response.status, + fallbackMessage: parsed.rawMessage, + }); - throw new ApiError({ - message, - status: response.status, - code, - requestId: parsed.requestId, - details: parsed.details, - rawMessage: parsed.rawMessage, - }); + throw new ApiError({ + message, + status: response.status, + code, + requestId: parsed.requestId, + details: parsed.details, + rawMessage: parsed.rawMessage, + }); + } + + return data as T; } - return data as T; - } - - return { - request, - }; + return { + request, + }; } diff --git a/src/api/query.ts b/src/api/query.ts index f800bc7..aab4f09 100644 --- a/src/api/query.ts +++ b/src/api/query.ts @@ -1,29 +1,29 @@ type BuildListQueryOptions = { - q?: string; - page?: number; - pageSize?: number; - sort?: string; - defaultSort: string; + q?: string; + page?: number; + pageSize?: number; + sort?: string; + defaultSort: string; }; export function buildListQuery({ - q, - page = 1, - pageSize = 10, - sort, - defaultSort, + q, + page = 1, + pageSize = 10, + sort, + defaultSort, }: BuildListQueryOptions): string { - const query = new URLSearchParams(); - const normalizedQuery = q?.trim(); - const normalizedSort = sort?.trim(); + const query = new URLSearchParams(); + const normalizedQuery = q?.trim(); + const normalizedSort = sort?.trim(); - if (normalizedQuery) { - query.set('q', normalizedQuery); - } + if (normalizedQuery) { + query.set('q', normalizedQuery); + } - query.set('page', String(page)); - query.set('pageSize', String(pageSize)); - query.set('sort', normalizedSort || defaultSort); + query.set('page', String(page)); + query.set('pageSize', String(pageSize)); + query.set('sort', normalizedSort || defaultSort); - return query.toString(); + return query.toString(); } diff --git a/src/auth/createAuthContext.tsx b/src/auth/createAuthContext.tsx index 25c302a..a0ea83d 100644 --- a/src/auth/createAuthContext.tsx +++ b/src/auth/createAuthContext.tsx @@ -6,91 +6,91 @@ const DEFAULT_REFRESH_TOKEN_KEY = 'refreshToken'; const DEFAULT_LEGACY_KEYS = ['auth_token', 'auth_user', 'token']; export type AuthState = { - authToken: string | null; - refreshToken: string | null; - currentUser: TUser | null; + authToken: string | null; + refreshToken: string | null; + currentUser: TUser | null; }; export type AuthContextValue = AuthState & { - setSession: (authToken: string, refreshToken: string, currentUser: TUser) => void; - setCurrentUser: (currentUser: TUser | null) => void; - clearSession: () => void; + setSession: (authToken: string, refreshToken: string, currentUser: TUser) => void; + setCurrentUser: (currentUser: TUser | null) => void; + clearSession: () => void; }; export type CreateAuthContextOptions = { - authTokenKey?: string; - refreshTokenKey?: string; - legacyKeys?: string[]; + authTokenKey?: string; + refreshTokenKey?: string; + legacyKeys?: string[]; }; export function createAuthContext(options: CreateAuthContextOptions = {}) { - const authTokenKey = options.authTokenKey ?? DEFAULT_AUTH_TOKEN_KEY; - const refreshTokenKey = options.refreshTokenKey ?? DEFAULT_REFRESH_TOKEN_KEY; - const legacyKeys = options.legacyKeys ?? DEFAULT_LEGACY_KEYS; + const authTokenKey = options.authTokenKey ?? DEFAULT_AUTH_TOKEN_KEY; + const refreshTokenKey = options.refreshTokenKey ?? DEFAULT_REFRESH_TOKEN_KEY; + const legacyKeys = options.legacyKeys ?? DEFAULT_LEGACY_KEYS; - const AuthContext = createContext | undefined>(undefined); + const AuthContext = createContext | undefined>(undefined); - function readStoredSession(): AuthState { - for (const key of legacyKeys) { - localStorage.removeItem(key); + function readStoredSession(): AuthState { + for (const key of legacyKeys) { + localStorage.removeItem(key); + } + + const authToken = localStorage.getItem(authTokenKey); + const refreshToken = localStorage.getItem(refreshTokenKey); + + return { + authToken, + refreshToken, + currentUser: null, + }; } - const authToken = localStorage.getItem(authTokenKey); - const refreshToken = localStorage.getItem(refreshTokenKey); + function AuthProvider({ children }: Readonly<{ children: ReactNode }>) { + const [state, setState] = useState>(readStoredSession); + + const setSession = useCallback( + (authToken: string, refreshToken: string, currentUser: TUser) => { + localStorage.setItem(authTokenKey, authToken); + localStorage.setItem(refreshTokenKey, refreshToken); + setState({ authToken, refreshToken, currentUser }); + }, + [], + ); + + const clearSession = useCallback(() => { + localStorage.removeItem(authTokenKey); + localStorage.removeItem(refreshTokenKey); + setState({ authToken: null, refreshToken: null, currentUser: null }); + }, []); + + const setCurrentUser = useCallback((currentUser: TUser | null) => { + setState((prev) => ({ ...prev, currentUser })); + }, []); + + const value = useMemo>( + () => ({ + ...state, + setSession, + setCurrentUser, + clearSession, + }), + [state, setSession, setCurrentUser, clearSession], + ); + + return {children}; + } + + function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth must be used within AuthProvider'); + } + return ctx; + } return { - authToken, - refreshToken, - currentUser: null, + AuthProvider, + useAuth, + AuthContext, }; - } - - function AuthProvider({ children }: Readonly<{ children: ReactNode }>) { - const [state, setState] = useState>(readStoredSession); - - const setSession = useCallback( - (authToken: string, refreshToken: string, currentUser: TUser) => { - localStorage.setItem(authTokenKey, authToken); - localStorage.setItem(refreshTokenKey, refreshToken); - setState({ authToken, refreshToken, currentUser }); - }, - [], - ); - - const clearSession = useCallback(() => { - localStorage.removeItem(authTokenKey); - localStorage.removeItem(refreshTokenKey); - setState({ authToken: null, refreshToken: null, currentUser: null }); - }, []); - - const setCurrentUser = useCallback((currentUser: TUser | null) => { - setState((prev) => ({ ...prev, currentUser })); - }, []); - - const value = useMemo>( - () => ({ - ...state, - setSession, - setCurrentUser, - clearSession, - }), - [state, setSession, setCurrentUser, clearSession], - ); - - return {children}; - } - - function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) { - throw new Error('useAuth must be used within AuthProvider'); - } - return ctx; - } - - return { - AuthProvider, - useAuth, - AuthContext, - }; } diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts index 34602be..3d6a34b 100644 --- a/src/auth/jwt.ts +++ b/src/auth/jwt.ts @@ -1,35 +1,35 @@ const MILLISECONDS_PER_SECOND = 1000; export function decodeJwtPayload(token: string): Record | null { - const parts = token.split('.'); - if (parts.length !== 3) { - return null; - } - - const base64Url = parts[1]; - const base64 = base64Url.replaceAll('-', '+').replaceAll('_', '/'); - const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); - - try { - const payload = JSON.parse(atob(padded)); - if (payload && typeof payload === 'object') { - return payload as Record; + const parts = token.split('.'); + if (parts.length !== 3) { + return null; } - } catch { - return null; - } - return null; + const base64Url = parts[1]; + const base64 = base64Url.replaceAll('-', '+').replaceAll('_', '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + + try { + const payload = JSON.parse(atob(padded)); + if (payload && typeof payload === 'object') { + return payload as Record; + } + } catch { + return null; + } + + return null; } export function isJwtExpired(token: string, skewSeconds = 0) { - const payload = decodeJwtPayload(token); - const exp = payload?.exp; - if (typeof exp !== 'number' || !Number.isFinite(exp)) { - return false; - } + const payload = decodeJwtPayload(token); + const exp = payload?.exp; + if (typeof exp !== 'number' || !Number.isFinite(exp)) { + return false; + } - const expiresAt = exp * MILLISECONDS_PER_SECOND; - const now = Date.now(); - return expiresAt <= now + skewSeconds * MILLISECONDS_PER_SECOND; + const expiresAt = exp * MILLISECONDS_PER_SECOND; + const now = Date.now(); + return expiresAt <= now + skewSeconds * MILLISECONDS_PER_SECOND; } diff --git a/src/contexts/LeftMenuContext.tsx b/src/contexts/LeftMenuContext.tsx index 84c7652..a4b7efe 100644 --- a/src/contexts/LeftMenuContext.tsx +++ b/src/contexts/LeftMenuContext.tsx @@ -1,13 +1,13 @@ import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - type CSSProperties, - type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, } from 'react'; import { isDesktopViewport, useSidePanelMachine } from '../panels/useSidePanelMachine'; @@ -19,207 +19,207 @@ const SIDEBAR_MAX_WIDTH = 420; const SIDEBAR_COLLAPSED_WIDTH = 56; export type LeftMenuRenderState = { - collapsed: boolean; - mobileOpen: boolean; - isDesktop: boolean; - closeMenu: () => void; + collapsed: boolean; + mobileOpen: boolean; + isDesktop: boolean; + closeMenu: () => void; }; export type LeftMenuContent = { - ariaLabel?: string; - render: (state: LeftMenuRenderState) => ReactNode; + ariaLabel?: string; + render: (state: LeftMenuRenderState) => ReactNode; }; export type LeftMenuStyle = CSSProperties & { - '--auth-sidebar-width': string; + '--auth-sidebar-width': string; }; type LeftMenuContextValue = { - collapsed: boolean; - mobileOpen: boolean; - content: LeftMenuContent; - desktopMenuStyle: LeftMenuStyle; - openMenu: (content?: LeftMenuContent) => void; - closeMenu: () => void; - toggleMenu: (content?: LeftMenuContent) => void; - expandMenu: () => void; - collapseMenu: () => void; - toggleCollapsed: () => void; - setMenuContent: (content: LeftMenuContent | null) => void; - startResize: ReturnType['startResize']; + collapsed: boolean; + mobileOpen: boolean; + content: LeftMenuContent; + desktopMenuStyle: LeftMenuStyle; + openMenu: (content?: LeftMenuContent) => void; + closeMenu: () => void; + toggleMenu: (content?: LeftMenuContent) => void; + expandMenu: () => void; + collapseMenu: () => void; + toggleCollapsed: () => void; + setMenuContent: (content: LeftMenuContent | null) => void; + startResize: ReturnType['startResize']; }; type LeftMenuProviderProps = { - children: ReactNode; - defaultContent: LeftMenuContent; - closeOnPathname?: string; + children: ReactNode; + defaultContent: LeftMenuContent; + closeOnPathname?: string; }; const LeftMenuContext = createContext(undefined); function readStoredCollapsed(): boolean { - if (!globalThis.window) { - return false; - } + if (!globalThis.window) { + return false; + } - return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1'; + return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1'; } export function LeftMenuProvider({ - children, - defaultContent, - closeOnPathname, -}: Readonly) { - const [collapsed, setCollapsed] = useState(() => readStoredCollapsed()); - const [mobileOpen, setMobileOpen] = useState(false); - const [content, setContent] = useState(defaultContent); - const defaultContentRef = useRef(defaultContent); - - useEffect(() => { - const previousDefaultContent = defaultContentRef.current; - defaultContentRef.current = defaultContent; - setContent((currentContent) => { - if (currentContent === previousDefaultContent) { - return defaultContent; - } - return currentContent; - }); - }, [defaultContent]); - - useEffect(() => { - if (!globalThis.window) { - return; - } - - localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? '1' : '0'); - }, [collapsed]); - - const expandMenu = useCallback(() => { - setCollapsed(false); - }, []); - - const collapseMenu = useCallback(() => { - setCollapsed(true); - }, []); - - const toggleCollapsed = useCallback(() => { - setCollapsed((previous) => !previous); - }, []); - - const closeMobile = useCallback(() => { - setMobileOpen(false); - }, []); - - const setMenuContent = useCallback((nextContent: LeftMenuContent | null) => { - setContent(nextContent ?? defaultContentRef.current); - }, []); - - const closeMenu = useCallback(() => { - if (isDesktopViewport()) { - collapseMenu(); - return; - } - - closeMobile(); - }, [collapseMenu, closeMobile]); - - const openMenu = useCallback( - (nextContent?: LeftMenuContent) => { - if (nextContent) { - setContent(nextContent); - } - - if (isDesktopViewport()) { - expandMenu(); - return; - } - - setMobileOpen(true); - }, - [expandMenu], - ); - - const toggleMenu = useCallback( - (nextContent?: LeftMenuContent) => { - if (nextContent) { - setContent(nextContent); - } - - if (isDesktopViewport()) { - toggleCollapsed(); - return; - } - - setMobileOpen((previous) => !previous); - }, - [toggleCollapsed], - ); - - const handleCloseOnPathname = useCallback(() => { - setMobileOpen(false); - setContent(defaultContentRef.current); - }, []); - - const { width, startResize } = useSidePanelMachine({ - storageKey: SIDEBAR_WIDTH_KEY, - defaultWidth: SIDEBAR_DEFAULT_WIDTH, - minWidth: SIDEBAR_MIN_WIDTH, - maxWidth: SIDEBAR_MAX_WIDTH, - resizeAxis: 'from-left', - resizingBodyClass: 'auth-sidebar-resizing', - isOpen: mobileOpen, - canResize: !collapsed, - shouldPersistWidth: !collapsed, + children, + defaultContent, closeOnPathname, - onCloseOnPathname: handleCloseOnPathname, - onEscape: closeMobile, - }); +}: Readonly) { + const [collapsed, setCollapsed] = useState(() => readStoredCollapsed()); + const [mobileOpen, setMobileOpen] = useState(false); + const [content, setContent] = useState(defaultContent); + const defaultContentRef = useRef(defaultContent); - const desktopMenuStyle = useMemo( - () => ({ - '--auth-sidebar-width': `${collapsed ? SIDEBAR_COLLAPSED_WIDTH : width}px`, - }), - [collapsed, width], - ); + useEffect(() => { + const previousDefaultContent = defaultContentRef.current; + defaultContentRef.current = defaultContent; + setContent((currentContent) => { + if (currentContent === previousDefaultContent) { + return defaultContent; + } + return currentContent; + }); + }, [defaultContent]); - const value = useMemo( - () => ({ - collapsed, - mobileOpen, - content, - desktopMenuStyle, - openMenu, - closeMenu, - toggleMenu, - expandMenu, - collapseMenu, - toggleCollapsed, - setMenuContent, - startResize, - }), - [ - collapsed, - mobileOpen, - content, - desktopMenuStyle, - openMenu, - closeMenu, - toggleMenu, - expandMenu, - collapseMenu, - toggleCollapsed, - setMenuContent, - startResize, - ], - ); + useEffect(() => { + if (!globalThis.window) { + return; + } - return {children}; + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? '1' : '0'); + }, [collapsed]); + + const expandMenu = useCallback(() => { + setCollapsed(false); + }, []); + + const collapseMenu = useCallback(() => { + setCollapsed(true); + }, []); + + const toggleCollapsed = useCallback(() => { + setCollapsed((previous) => !previous); + }, []); + + const closeMobile = useCallback(() => { + setMobileOpen(false); + }, []); + + const setMenuContent = useCallback((nextContent: LeftMenuContent | null) => { + setContent(nextContent ?? defaultContentRef.current); + }, []); + + const closeMenu = useCallback(() => { + if (isDesktopViewport()) { + collapseMenu(); + return; + } + + closeMobile(); + }, [collapseMenu, closeMobile]); + + const openMenu = useCallback( + (nextContent?: LeftMenuContent) => { + if (nextContent) { + setContent(nextContent); + } + + if (isDesktopViewport()) { + expandMenu(); + return; + } + + setMobileOpen(true); + }, + [expandMenu], + ); + + const toggleMenu = useCallback( + (nextContent?: LeftMenuContent) => { + if (nextContent) { + setContent(nextContent); + } + + if (isDesktopViewport()) { + toggleCollapsed(); + return; + } + + setMobileOpen((previous) => !previous); + }, + [toggleCollapsed], + ); + + const handleCloseOnPathname = useCallback(() => { + setMobileOpen(false); + setContent(defaultContentRef.current); + }, []); + + const { width, startResize } = useSidePanelMachine({ + storageKey: SIDEBAR_WIDTH_KEY, + defaultWidth: SIDEBAR_DEFAULT_WIDTH, + minWidth: SIDEBAR_MIN_WIDTH, + maxWidth: SIDEBAR_MAX_WIDTH, + resizeAxis: 'from-left', + resizingBodyClass: 'auth-sidebar-resizing', + isOpen: mobileOpen, + canResize: !collapsed, + shouldPersistWidth: !collapsed, + closeOnPathname, + onCloseOnPathname: handleCloseOnPathname, + onEscape: closeMobile, + }); + + const desktopMenuStyle = useMemo( + () => ({ + '--auth-sidebar-width': `${collapsed ? SIDEBAR_COLLAPSED_WIDTH : width}px`, + }), + [collapsed, width], + ); + + const value = useMemo( + () => ({ + collapsed, + mobileOpen, + content, + desktopMenuStyle, + openMenu, + closeMenu, + toggleMenu, + expandMenu, + collapseMenu, + toggleCollapsed, + setMenuContent, + startResize, + }), + [ + collapsed, + mobileOpen, + content, + desktopMenuStyle, + openMenu, + closeMenu, + toggleMenu, + expandMenu, + collapseMenu, + toggleCollapsed, + setMenuContent, + startResize, + ], + ); + + return {children}; } export function useLeftMenu() { - const ctx = useContext(LeftMenuContext); - if (!ctx) { - throw new Error('useLeftMenu must be used within LeftMenuProvider'); - } - return ctx; + const ctx = useContext(LeftMenuContext); + if (!ctx) { + throw new Error('useLeftMenu must be used within LeftMenuProvider'); + } + return ctx; } diff --git a/src/contexts/RightSidebarContext.tsx b/src/contexts/RightSidebarContext.tsx index 5c7b3da..5551487 100644 --- a/src/contexts/RightSidebarContext.tsx +++ b/src/contexts/RightSidebarContext.tsx @@ -1,11 +1,11 @@ import { - createContext, - useCallback, - useContext, - useMemo, - useState, - type CSSProperties, - type ReactNode, + createContext, + useCallback, + useContext, + useMemo, + useState, + type CSSProperties, + type ReactNode, } from 'react'; import { isDesktopViewport, useSidePanelMachine } from '../panels/useSidePanelMachine'; @@ -15,133 +15,133 @@ const RIGHT_SIDEBAR_MIN_WIDTH = 260; const RIGHT_SIDEBAR_MAX_WIDTH = 480; export type RightSidebarContent = { - title: string; - content: ReactNode; - ariaLabel?: string; + title: string; + content: ReactNode; + ariaLabel?: string; }; export type RightSidebarStyle = CSSProperties & { - '--auth-right-sidebar-width': string; + '--auth-right-sidebar-width': string; }; type RightSidebarContextValue = { - isOpen: boolean; - content: RightSidebarContent | null; - openSidebar: (content?: RightSidebarContent) => void; - closeSidebar: () => void; - toggleSidebar: (content?: RightSidebarContent) => void; - setSidebarContent: (content: RightSidebarContent | null) => void; - desktopSidebarStyle: RightSidebarStyle; - startResize: ReturnType['startResize']; + isOpen: boolean; + content: RightSidebarContent | null; + openSidebar: (content?: RightSidebarContent) => void; + closeSidebar: () => void; + toggleSidebar: (content?: RightSidebarContent) => void; + setSidebarContent: (content: RightSidebarContent | null) => void; + desktopSidebarStyle: RightSidebarStyle; + startResize: ReturnType['startResize']; }; type RightSidebarProviderProps = { - children: ReactNode; - closeOnPathname?: string; - onMobileOpenRequest?: () => void; + children: ReactNode; + closeOnPathname?: string; + onMobileOpenRequest?: () => void; }; const RightSidebarContext = createContext(undefined); export function RightSidebarProvider({ - children, - closeOnPathname, - onMobileOpenRequest, -}: Readonly) { - const [isOpen, setIsOpen] = useState(false); - const [content, setContent] = useState(null); - - const closeSidebar = useCallback(() => { - setIsOpen(false); - setContent(null); - }, []); - - const setSidebarContent = useCallback((nextContent: RightSidebarContent | null) => { - setContent(nextContent); - }, []); - - const openSidebar = useCallback( - (nextContent?: RightSidebarContent) => { - const resolvedContent = nextContent ?? content; - if (!resolvedContent) { - return; - } - - if (nextContent) { - setContent(nextContent); - } - if (!isDesktopViewport()) { - onMobileOpenRequest?.(); - } - setIsOpen(true); - }, - [content, onMobileOpenRequest], - ); - - const toggleSidebar = useCallback( - (nextContent?: RightSidebarContent) => { - if (isOpen) { - closeSidebar(); - return; - } - - openSidebar(nextContent); - }, - [isOpen, closeSidebar, openSidebar], - ); - - const { width, startResize } = useSidePanelMachine({ - storageKey: RIGHT_SIDEBAR_WIDTH_KEY, - defaultWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH, - minWidth: RIGHT_SIDEBAR_MIN_WIDTH, - maxWidth: RIGHT_SIDEBAR_MAX_WIDTH, - resizeAxis: 'from-right', - resizingBodyClass: 'auth-right-sidebar-resizing', - isOpen, - canResize: isOpen, - shouldPersistWidth: true, + children, closeOnPathname, - onCloseOnPathname: closeSidebar, - onEscape: closeSidebar, - }); + onMobileOpenRequest, +}: Readonly) { + const [isOpen, setIsOpen] = useState(false); + const [content, setContent] = useState(null); - const desktopSidebarStyle = useMemo( - () => ({ - '--auth-right-sidebar-width': `${width}px`, - }), - [width], - ); + const closeSidebar = useCallback(() => { + setIsOpen(false); + setContent(null); + }, []); - const value = useMemo( - () => ({ - isOpen, - content, - openSidebar, - closeSidebar, - toggleSidebar, - setSidebarContent, - desktopSidebarStyle, - startResize, - }), - [ - isOpen, - content, - openSidebar, - closeSidebar, - toggleSidebar, - setSidebarContent, - desktopSidebarStyle, - startResize, - ], - ); + const setSidebarContent = useCallback((nextContent: RightSidebarContent | null) => { + setContent(nextContent); + }, []); - return {children}; + const openSidebar = useCallback( + (nextContent?: RightSidebarContent) => { + const resolvedContent = nextContent ?? content; + if (!resolvedContent) { + return; + } + + if (nextContent) { + setContent(nextContent); + } + if (!isDesktopViewport()) { + onMobileOpenRequest?.(); + } + setIsOpen(true); + }, + [content, onMobileOpenRequest], + ); + + const toggleSidebar = useCallback( + (nextContent?: RightSidebarContent) => { + if (isOpen) { + closeSidebar(); + return; + } + + openSidebar(nextContent); + }, + [isOpen, closeSidebar, openSidebar], + ); + + const { width, startResize } = useSidePanelMachine({ + storageKey: RIGHT_SIDEBAR_WIDTH_KEY, + defaultWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH, + minWidth: RIGHT_SIDEBAR_MIN_WIDTH, + maxWidth: RIGHT_SIDEBAR_MAX_WIDTH, + resizeAxis: 'from-right', + resizingBodyClass: 'auth-right-sidebar-resizing', + isOpen, + canResize: isOpen, + shouldPersistWidth: true, + closeOnPathname, + onCloseOnPathname: closeSidebar, + onEscape: closeSidebar, + }); + + const desktopSidebarStyle = useMemo( + () => ({ + '--auth-right-sidebar-width': `${width}px`, + }), + [width], + ); + + const value = useMemo( + () => ({ + isOpen, + content, + openSidebar, + closeSidebar, + toggleSidebar, + setSidebarContent, + desktopSidebarStyle, + startResize, + }), + [ + isOpen, + content, + openSidebar, + closeSidebar, + toggleSidebar, + setSidebarContent, + desktopSidebarStyle, + startResize, + ], + ); + + return {children}; } export function useRightSidebar() { - const ctx = useContext(RightSidebarContext); - if (!ctx) { - throw new Error('useRightSidebar must be used within RightSidebarProvider'); - } - return ctx; + const ctx = useContext(RightSidebarContext); + if (!ctx) { + throw new Error('useRightSidebar must be used within RightSidebarProvider'); + } + return ctx; } diff --git a/src/errors/createErrorResolver.ts b/src/errors/createErrorResolver.ts index 5cc1a74..21115cb 100644 --- a/src/errors/createErrorResolver.ts +++ b/src/errors/createErrorResolver.ts @@ -1,132 +1,132 @@ export type ErrorCatalog = Record; type ErrorLike = { - code?: unknown; - status?: unknown; - message?: unknown; - rawMessage?: unknown; + code?: unknown; + status?: unknown; + message?: unknown; + rawMessage?: unknown; }; export type ResolveErrorMessageOptions = { - code?: string | null; - status?: number | null; - context?: string; - fallbackMessage?: string | null; + code?: string | null; + status?: number | null; + context?: string; + fallbackMessage?: string | null; }; export type CreateErrorResolverConfig = { - catalog: ErrorCatalog; - fallbackCode?: string; - defaultContext?: string; - contextOverrides?: Record>>; - inferCodeFromStatus?: (status?: number | null) => string | undefined; - inferCodeFromLegacyMessage?: (message?: string | null) => string | undefined; + catalog: ErrorCatalog; + fallbackCode?: string; + defaultContext?: string; + contextOverrides?: Record>>; + inferCodeFromStatus?: (status?: number | null) => string | undefined; + inferCodeFromLegacyMessage?: (message?: string | null) => string | undefined; }; export function createErrorResolver(config: CreateErrorResolverConfig) { - const { - catalog, - fallbackCode, - defaultContext = 'default', - contextOverrides = {}, - inferCodeFromStatus, - inferCodeFromLegacyMessage, - } = config; + const { + catalog, + fallbackCode, + defaultContext = 'default', + contextOverrides = {}, + inferCodeFromStatus, + inferCodeFromLegacyMessage, + } = config; - const knownCodes = new Set(Object.keys(catalog)); + const knownCodes = new Set(Object.keys(catalog)); - function isKnownErrorCode(value: string): boolean { - return knownCodes.has(value); - } - - function normalizeErrorCode(code?: string | null): string | undefined { - if (!code) { - return undefined; - } - return isKnownErrorCode(code) ? code : undefined; - } - - function inferErrorCodeFromStatus(status?: number | null): string | undefined { - return inferCodeFromStatus?.(status); - } - - function resolveErrorMessage(options: ResolveErrorMessageOptions): string { - const { code, status, context = defaultContext, fallbackMessage } = options; - - const resolvedCode = - normalizeErrorCode(code) ?? - inferCodeFromLegacyMessage?.(fallbackMessage) ?? - inferErrorCodeFromStatus(status); - - if (resolvedCode) { - const contextMessage = contextOverrides[context]?.[resolvedCode]; - if (contextMessage) { - return contextMessage; - } - const catalogMessage = catalog[resolvedCode]; - if (catalogMessage) { - return catalogMessage; - } + function isKnownErrorCode(value: string): boolean { + return knownCodes.has(value); } - const statusCode = inferErrorCodeFromStatus(status); - if (statusCode) { - const contextMessage = contextOverrides[context]?.[statusCode]; - if (contextMessage) { - return contextMessage; - } - const catalogMessage = catalog[statusCode]; - if (catalogMessage) { - return catalogMessage; - } + function normalizeErrorCode(code?: string | null): string | undefined { + if (!code) { + return undefined; + } + return isKnownErrorCode(code) ? code : undefined; } - if (fallbackCode && catalog[fallbackCode]) { - return catalog[fallbackCode]; + function inferErrorCodeFromStatus(status?: number | null): string | undefined { + return inferCodeFromStatus?.(status); } - if (fallbackMessage) { - return fallbackMessage; + function resolveErrorMessage(options: ResolveErrorMessageOptions): string { + const { code, status, context = defaultContext, fallbackMessage } = options; + + const resolvedCode = + normalizeErrorCode(code) ?? + inferCodeFromLegacyMessage?.(fallbackMessage) ?? + inferErrorCodeFromStatus(status); + + if (resolvedCode) { + const contextMessage = contextOverrides[context]?.[resolvedCode]; + if (contextMessage) { + return contextMessage; + } + const catalogMessage = catalog[resolvedCode]; + if (catalogMessage) { + return catalogMessage; + } + } + + const statusCode = inferErrorCodeFromStatus(status); + if (statusCode) { + const contextMessage = contextOverrides[context]?.[statusCode]; + if (contextMessage) { + return contextMessage; + } + const catalogMessage = catalog[statusCode]; + if (catalogMessage) { + return catalogMessage; + } + } + + if (fallbackCode && catalog[fallbackCode]) { + return catalog[fallbackCode]; + } + + if (fallbackMessage) { + return fallbackMessage; + } + + return 'Request failed. Please try again.'; } - return 'Request failed. Please try again.'; - } - - function resolveOptionalErrorMessage( - code?: string | null, - context: string = defaultContext, - ): string | undefined { - if (!code) { - return undefined; - } - return resolveErrorMessage({ code, context }); - } - - function toErrorMessage(err: unknown, context: string = defaultContext): string { - if (err && typeof err === 'object') { - const errorLike = err as ErrorLike; - const code = typeof errorLike.code === 'string' ? errorLike.code : undefined; - const status = typeof errorLike.status === 'number' ? errorLike.status : undefined; - const rawMessage = - typeof errorLike.rawMessage === 'string' ? errorLike.rawMessage : undefined; - const message = typeof errorLike.message === 'string' ? errorLike.message : undefined; - - return resolveErrorMessage({ - code, - status, - context, - fallbackMessage: rawMessage ?? message, - }); + function resolveOptionalErrorMessage( + code?: string | null, + context: string = defaultContext, + ): string | undefined { + if (!code) { + return undefined; + } + return resolveErrorMessage({ code, context }); } - return resolveErrorMessage({ context }); - } + function toErrorMessage(err: unknown, context: string = defaultContext): string { + if (err && typeof err === 'object') { + const errorLike = err as ErrorLike; + const code = typeof errorLike.code === 'string' ? errorLike.code : undefined; + const status = typeof errorLike.status === 'number' ? errorLike.status : undefined; + const rawMessage = + typeof errorLike.rawMessage === 'string' ? errorLike.rawMessage : undefined; + const message = typeof errorLike.message === 'string' ? errorLike.message : undefined; - return { - isKnownErrorCode, - inferErrorCodeFromStatus, - resolveErrorMessage, - resolveOptionalErrorMessage, - toErrorMessage, - }; + return resolveErrorMessage({ + code, + status, + context, + fallbackMessage: rawMessage ?? message, + }); + } + + return resolveErrorMessage({ context }); + } + + return { + isKnownErrorCode, + inferErrorCodeFromStatus, + resolveErrorMessage, + resolveOptionalErrorMessage, + toErrorMessage, + }; } diff --git a/src/hooks/useCooldownTimer.ts b/src/hooks/useCooldownTimer.ts index 360b8a6..dacb824 100644 --- a/src/hooks/useCooldownTimer.ts +++ b/src/hooks/useCooldownTimer.ts @@ -1,28 +1,28 @@ import { useCallback, useEffect, useState } from 'react'; export function useCooldownTimer(seconds = 0, enabled = true) { - const [cooldown, setCooldown] = useState(seconds); + const [cooldown, setCooldown] = useState(seconds); - useEffect(() => { - if (!enabled || cooldown <= 0) { - return; - } + useEffect(() => { + if (!enabled || cooldown <= 0) { + return; + } - const timer = globalThis.setInterval(() => { - setCooldown((prev) => (prev > 0 ? prev - 1 : 0)); - }, 1000); + const timer = globalThis.setInterval(() => { + setCooldown((prev) => (prev > 0 ? prev - 1 : 0)); + }, 1000); - return () => { - globalThis.clearInterval(timer); + return () => { + globalThis.clearInterval(timer); + }; + }, [enabled, cooldown]); + + const startCooldown = useCallback((seconds: number) => { + setCooldown(Math.max(0, Math.floor(seconds))); + }, []); + + return { + cooldown, + startCooldown, }; - }, [enabled, cooldown]); - - const startCooldown = useCallback((seconds: number) => { - setCooldown(Math.max(0, Math.floor(seconds))); - }, []); - - return { - cooldown, - startCooldown, - }; } diff --git a/src/hooks/useEditableForm.ts b/src/hooks/useEditableForm.ts index 16295da..2a69d37 100644 --- a/src/hooks/useEditableForm.ts +++ b/src/hooks/useEditableForm.ts @@ -4,77 +4,77 @@ import { useValidatedFields } from './useValidatedFields'; type FieldErrors = Partial>; type UseEditableFormOptions = { - initialValues: TValues; - validate: (values: TValues) => FieldErrors; + initialValues: TValues; + validate: (values: TValues) => FieldErrors; }; export function useEditableForm>({ - initialValues, - validate, -}: UseEditableFormOptions) { - const [isEditing, setIsEditing] = useState(false); - - const { - values, - errors, - isValid, - setValues, - setFieldValue, - validateAll, - setFieldError, - setErrors, - clearErrors, - } = useValidatedFields({ initialValues, validate, - }); +}: UseEditableFormOptions) { + const [isEditing, setIsEditing] = useState(false); - const startEditing = useCallback( - (sourceValues: TValues) => { - setValues(sourceValues, { validate: true }); - setIsEditing(true); - }, - [setValues], - ); + const { + values, + errors, + isValid, + setValues, + setFieldValue, + validateAll, + setFieldError, + setErrors, + clearErrors, + } = useValidatedFields({ + initialValues, + validate, + }); - const discardChanges = useCallback( - (sourceValues: TValues) => { - setValues(sourceValues, { clearErrors: true }); - setIsEditing(false); - }, - [setValues], - ); + const startEditing = useCallback( + (sourceValues: TValues) => { + setValues(sourceValues, { validate: true }); + setIsEditing(true); + }, + [setValues], + ); - const loadFromSource = useCallback( - (sourceValues: TValues) => { - setValues(sourceValues, { clearErrors: true }); - }, - [setValues], - ); + const discardChanges = useCallback( + (sourceValues: TValues) => { + setValues(sourceValues, { clearErrors: true }); + setIsEditing(false); + }, + [setValues], + ); - const commitSaved = useCallback( - (sourceValues: TValues) => { - setValues(sourceValues, { clearErrors: true }); - setIsEditing(false); - }, - [setValues], - ); + const loadFromSource = useCallback( + (sourceValues: TValues) => { + setValues(sourceValues, { clearErrors: true }); + }, + [setValues], + ); - return { - values, - errors, - isValid, - setValues, - setFieldValue, - validateAll, - setFieldError, - setErrors, - clearErrors, - isEditing, - startEditing, - discardChanges, - loadFromSource, - commitSaved, - setIsEditing, - }; + const commitSaved = useCallback( + (sourceValues: TValues) => { + setValues(sourceValues, { clearErrors: true }); + setIsEditing(false); + }, + [setValues], + ); + + return { + values, + errors, + isValid, + setValues, + setFieldValue, + validateAll, + setFieldError, + setErrors, + clearErrors, + isEditing, + startEditing, + discardChanges, + loadFromSource, + commitSaved, + setIsEditing, + }; } diff --git a/src/hooks/usePaginatedResource.ts b/src/hooks/usePaginatedResource.ts index a1fbe05..2d1cbfb 100644 --- a/src/hooks/usePaginatedResource.ts +++ b/src/hooks/usePaginatedResource.ts @@ -1,107 +1,111 @@ import { useCallback, useEffect, useState } from 'react'; type PaginatedResourceResponse = { - items: TItem[]; - page: number; - pageSize: number; - total: number; - totalPages: number; + items: TItem[]; + page: number; + pageSize: number; + total: number; + totalPages: number; }; type UsePaginatedResourceOptions = { - load: (params: { - q: string; - page: number; - pageSize: number; + load: (params: { + q: string; + page: number; + pageSize: number; + sort?: string; + }) => Promise>; sort?: string; - }) => Promise>; - sort?: string; - debounceMs?: number; - initialQuery?: string; - initialPage?: number; - initialPageSize?: number; + debounceMs?: number; + initialQuery?: string; + initialPage?: number; + initialPageSize?: number; }; export function usePaginatedResource({ - load, - sort, - debounceMs = 250, - initialQuery = '', - initialPage = 1, - initialPageSize = 10, + load, + sort, + debounceMs = 250, + initialQuery = '', + initialPage = 1, + initialPageSize = 10, }: UsePaginatedResourceOptions) { - const [items, setItems] = useState([]); - const [q, setQ] = useState(initialQuery); - const [page, setPage] = useState(initialPage); - const [pageSize, setPageSize] = useState(initialPageSize); - const [total, setTotal] = useState(0); - const [totalPages, setTotalPages] = useState(0); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [items, setItems] = useState([]); + const [q, setQ] = useState(initialQuery); + const [page, setPage] = useState(initialPage); + const [pageSize, setPageSize] = useState(initialPageSize); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - let cancelled = false; - setIsLoading(true); - setError(null); + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setError(null); - const timer = setTimeout(() => { - void (async () => { - try { - const response = await load({ - q, - page, - pageSize, - sort, - }); + const timer = setTimeout(() => { + void (async () => { + try { + const response = await load({ + q, + page, + pageSize, + sort, + }); - if (cancelled) { - return; - } + if (cancelled) { + return; + } - setItems(response.items); - setTotal(response.total); - setTotalPages(response.totalPages); - setPage(response.page); - setPageSize(response.pageSize); - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : 'Request failed. Please try again.'); - } - } finally { - if (!cancelled) { - setIsLoading(false); - } - } - })(); - }, debounceMs); + setItems(response.items); + setTotal(response.total); + setTotalPages(response.totalPages); + setPage(response.page); + setPageSize(response.pageSize); + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error + ? err.message + : 'Request failed. Please try again.', + ); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + })(); + }, debounceMs); - return () => { - cancelled = true; - clearTimeout(timer); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [q, page, pageSize, sort, load, debounceMs]); + + const setQuery = useCallback((value: string) => { + setQ(value); + setPage(1); + }, []); + + const setPageSizeAndResetPage = useCallback((value: number) => { + setPageSize(value); + setPage(1); + }, []); + + return { + items, + q, + page, + pageSize, + total, + totalPages, + error, + isLoading, + setQuery, + setPage, + setPageSize: setPageSizeAndResetPage, }; - }, [q, page, pageSize, sort, load, debounceMs]); - - const setQuery = useCallback((value: string) => { - setQ(value); - setPage(1); - }, []); - - const setPageSizeAndResetPage = useCallback((value: number) => { - setPageSize(value); - setPage(1); - }, []); - - return { - items, - q, - page, - pageSize, - total, - totalPages, - error, - isLoading, - setQuery, - setPage, - setPageSize: setPageSizeAndResetPage, - }; } diff --git a/src/hooks/useSorting.ts b/src/hooks/useSorting.ts index 682dbb6..3c5a41c 100644 --- a/src/hooks/useSorting.ts +++ b/src/hooks/useSorting.ts @@ -3,79 +3,79 @@ import { useCallback, useMemo, useState } from 'react'; export type SortDirection = 'asc' | 'desc'; export type SortState = { - field: string; - direction: SortDirection; + field: string; + direction: SortDirection; }; function invertDirection(direction: SortDirection): SortDirection { - return direction === 'asc' ? 'desc' : 'asc'; + return direction === 'asc' ? 'desc' : 'asc'; } export function formatSortParam(sort: SortState | null | undefined): string | undefined { - if (!sort) { - return undefined; - } - return sort.direction === 'desc' ? `-${sort.field}` : sort.field; + if (!sort) { + return undefined; + } + return sort.direction === 'desc' ? `-${sort.field}` : sort.field; } type UseSortingResult = { - activeSort: SortState | null; - sortParam: string | undefined; - toggleSort: (field: string) => void; - setSort: (next: SortState | null) => void; - resetSort: () => void; + activeSort: SortState | null; + sortParam: string | undefined; + toggleSort: (field: string) => void; + setSort: (next: SortState | null) => void; + resetSort: () => void; }; export function useSorting(defaultSort?: SortState | null): UseSortingResult { - const [overrideSort, setOverrideSort] = useState(null); + const [overrideSort, setOverrideSort] = useState(null); - const activeSort = overrideSort ?? defaultSort ?? null; + const activeSort = overrideSort ?? defaultSort ?? null; - const toggleSort = useCallback( - (field: string) => { - setOverrideSort((previousOverride) => { - const baselineSort = defaultSort ?? null; - const currentSort = previousOverride ?? baselineSort; + const toggleSort = useCallback( + (field: string) => { + setOverrideSort((previousOverride) => { + const baselineSort = defaultSort ?? null; + const currentSort = previousOverride ?? baselineSort; - if (!currentSort || currentSort.field !== field) { - return { field, direction: 'asc' }; - } + if (!currentSort || currentSort.field !== field) { + return { field, direction: 'asc' }; + } - if (baselineSort && baselineSort.field === field) { - if (previousOverride == null) { - return { field, direction: invertDirection(baselineSort.direction) }; - } - if (previousOverride.direction === baselineSort.direction) { - return { field, direction: invertDirection(baselineSort.direction) }; - } - return null; - } + if (baselineSort && baselineSort.field === field) { + if (previousOverride == null) { + return { field, direction: invertDirection(baselineSort.direction) }; + } + if (previousOverride.direction === baselineSort.direction) { + return { field, direction: invertDirection(baselineSort.direction) }; + } + return null; + } - if (previousOverride == null || previousOverride.direction === 'desc') { - return null; - } + if (previousOverride == null || previousOverride.direction === 'desc') { + return null; + } - return { field, direction: 'desc' }; - }); - }, - [defaultSort], - ); + return { field, direction: 'desc' }; + }); + }, + [defaultSort], + ); - const setSort = useCallback((next: SortState | null) => { - setOverrideSort(next); - }, []); + const setSort = useCallback((next: SortState | null) => { + setOverrideSort(next); + }, []); - const resetSort = useCallback(() => { - setOverrideSort(null); - }, []); + const resetSort = useCallback(() => { + setOverrideSort(null); + }, []); - const sortParam = useMemo(() => formatSortParam(activeSort), [activeSort]); + const sortParam = useMemo(() => formatSortParam(activeSort), [activeSort]); - return { - activeSort, - sortParam, - toggleSort, - setSort, - resetSort, - }; + return { + activeSort, + sortParam, + toggleSort, + setSort, + resetSort, + }; } diff --git a/src/hooks/useSubmitState.ts b/src/hooks/useSubmitState.ts index 78bd15f..bf80d04 100644 --- a/src/hooks/useSubmitState.ts +++ b/src/hooks/useSubmitState.ts @@ -1,31 +1,31 @@ import { useCallback, useState } from 'react'; export function useSubmitState(initialStatus: TStatus) { - const [isSubmitting, setIsSubmitting] = useState(false); - const [submitError, setSubmitError] = useState(null); - const [status, setStatus] = useState(initialStatus); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [status, setStatus] = useState(initialStatus); - const startSubmitting = useCallback(() => { - setIsSubmitting(true); - }, []); + const startSubmitting = useCallback(() => { + setIsSubmitting(true); + }, []); - const finishSubmitting = useCallback(() => { - setIsSubmitting(false); - }, []); + const finishSubmitting = useCallback(() => { + setIsSubmitting(false); + }, []); - const clearFeedback = useCallback(() => { - setSubmitError(null); - setStatus(initialStatus); - }, [initialStatus]); + const clearFeedback = useCallback(() => { + setSubmitError(null); + setStatus(initialStatus); + }, [initialStatus]); - return { - isSubmitting, - submitError, - status, - startSubmitting, - finishSubmitting, - setSubmitError, - setStatus, - clearFeedback, - }; + return { + isSubmitting, + submitError, + status, + startSubmitting, + finishSubmitting, + setSubmitError, + setStatus, + clearFeedback, + }; } diff --git a/src/hooks/useValidatedFields.ts b/src/hooks/useValidatedFields.ts index 931bde9..ddf1837 100644 --- a/src/hooks/useValidatedFields.ts +++ b/src/hooks/useValidatedFields.ts @@ -4,168 +4,172 @@ type FieldErrors = Partial>; type TouchedFields = Partial>; type SetValuesOptions = { - validate?: boolean; - clearErrors?: boolean; + validate?: boolean; + clearErrors?: boolean; }; type SetFieldValueOptions = { - validate?: boolean; - touch?: boolean; + validate?: boolean; + touch?: boolean; }; type ValidateAllOptions = { - touchAll?: boolean; + touchAll?: boolean; }; type UseValidatedFieldsOptions = { - initialValues: TValues; - validate: (values: TValues) => FieldErrors; + initialValues: TValues; + validate: (values: TValues) => FieldErrors; }; function hasErrors(errors: FieldErrors): boolean { - return Object.values(errors).some(Boolean); + return Object.values(errors).some(Boolean); } function pickTouchedErrors( - errors: FieldErrors, - touched: TouchedFields, + errors: FieldErrors, + touched: TouchedFields, ): FieldErrors { - const next: FieldErrors = {}; + const next: FieldErrors = {}; - for (const key of Object.keys(errors) as Array) { - if (touched[key]) { - next[key] = errors[key]; + for (const key of Object.keys(errors) as Array) { + if (touched[key]) { + next[key] = errors[key]; + } } - } - return next; + return next; } function touchAll>(values: TValues): TouchedFields { - const touched: TouchedFields = {}; + const touched: TouchedFields = {}; - for (const key of Object.keys(values) as Array) { - touched[key] = true; - } + for (const key of Object.keys(values) as Array) { + touched[key] = true; + } - return touched; + return touched; } export function useValidatedFields>({ - initialValues, - validate, + initialValues, + validate, }: UseValidatedFieldsOptions) { - const [values, setValues] = useState(initialValues); - const [allErrors, setAllErrors] = useState>(() => validate(initialValues)); - const [touched, setTouched] = useState>({}); + const [values, setValues] = useState(initialValues); + const [allErrors, setAllErrors] = useState>(() => validate(initialValues)); + const [touched, setTouched] = useState>({}); - const updateValues = useCallback( - (nextValues: TValues, options: SetValuesOptions = {}) => { - const { validate: shouldValidate = false, clearErrors = false } = options; - setValues(nextValues); + const updateValues = useCallback( + (nextValues: TValues, options: SetValuesOptions = {}) => { + const { validate: shouldValidate = false, clearErrors = false } = options; + setValues(nextValues); - if (shouldValidate || clearErrors) { - setAllErrors(validate(nextValues)); - } + if (shouldValidate || clearErrors) { + setAllErrors(validate(nextValues)); + } - if (clearErrors) { - setTouched({}); - } - }, - [validate], - ); + if (clearErrors) { + setTouched({}); + } + }, + [validate], + ); - const setFieldValue = useCallback( - (key: K, value: TValues[K], options: SetFieldValueOptions = {}) => { - const { validate: shouldValidate = true, touch = true } = options; + const setFieldValue = useCallback( + ( + key: K, + value: TValues[K], + options: SetFieldValueOptions = {}, + ) => { + const { validate: shouldValidate = true, touch = true } = options; - if (touch) { + if (touch) { + setTouched((current) => ({ + ...current, + [key]: true, + })); + } + + setValues((current) => { + const nextValues = { + ...current, + [key]: value, + }; + + if (shouldValidate) { + setAllErrors(validate(nextValues)); + } + + return nextValues; + }); + }, + [validate], + ); + + const validateAll = useCallback( + (options: ValidateAllOptions = {}) => { + const { touchAll: shouldTouchAll = true } = options; + const nextErrors = validate(values); + + setAllErrors(nextErrors); + + if (shouldTouchAll) { + setTouched(touchAll(values)); + } + + return nextErrors; + }, + [validate, values], + ); + + const setFieldError = useCallback((key: K, message?: string) => { setTouched((current) => ({ - ...current, - [key]: true, + ...current, + [key]: true, })); - } - setValues((current) => { - const nextValues = { - ...current, - [key]: value, - }; + setAllErrors((current) => ({ + ...current, + [key]: message, + })); + }, []); - if (shouldValidate) { - setAllErrors(validate(nextValues)); + const updateErrors = useCallback((nextErrors: FieldErrors) => { + const nextTouched: TouchedFields = {}; + + for (const key of Object.keys(nextErrors) as Array) { + if (nextErrors[key]) { + nextTouched[key] = true; + } } - return nextValues; - }); - }, - [validate], - ); + setTouched((current) => ({ + ...current, + ...nextTouched, + })); + setAllErrors(nextErrors); + }, []); - const validateAll = useCallback( - (options: ValidateAllOptions = {}) => { - const { touchAll: shouldTouchAll = true } = options; - const nextErrors = validate(values); + const clearErrors = useCallback(() => { + setAllErrors(validate(values)); + setTouched({}); + }, [validate, values]); - setAllErrors(nextErrors); + const errors = useMemo(() => pickTouchedErrors(allErrors, touched), [allErrors, touched]); - if (shouldTouchAll) { - setTouched(touchAll(values)); - } + const isValid = useMemo(() => { + return !hasErrors(validate(values)); + }, [validate, values]); - return nextErrors; - }, - [validate, values], - ); - - const setFieldError = useCallback((key: K, message?: string) => { - setTouched((current) => ({ - ...current, - [key]: true, - })); - - setAllErrors((current) => ({ - ...current, - [key]: message, - })); - }, []); - - const updateErrors = useCallback((nextErrors: FieldErrors) => { - const nextTouched: TouchedFields = {}; - - for (const key of Object.keys(nextErrors) as Array) { - if (nextErrors[key]) { - nextTouched[key] = true; - } - } - - setTouched((current) => ({ - ...current, - ...nextTouched, - })); - setAllErrors(nextErrors); - }, []); - - const clearErrors = useCallback(() => { - setAllErrors(validate(values)); - setTouched({}); - }, [validate, values]); - - const errors = useMemo(() => pickTouchedErrors(allErrors, touched), [allErrors, touched]); - - const isValid = useMemo(() => { - return !hasErrors(validate(values)); - }, [validate, values]); - - return { - values, - errors, - isValid, - setValues: updateValues, - setFieldValue, - validateAll, - setFieldError, - setErrors: updateErrors, - clearErrors, - }; + return { + values, + errors, + isValid, + setValues: updateValues, + setFieldValue, + validateAll, + setFieldError, + setErrors: updateErrors, + clearErrors, + }; } diff --git a/src/index.ts b/src/index.ts index 9648dbb..3bc8b16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,26 @@ export { createAuthContext } from './auth/createAuthContext'; export type { - AuthContextValue, - AuthState, - CreateAuthContextOptions, + AuthContextValue, + AuthState, + CreateAuthContextOptions, } from './auth/createAuthContext'; export { decodeJwtPayload, isJwtExpired } from './auth/jwt'; export { createApiClient, ApiError } from './api/createApiClient'; export type { - CreateApiClientConfig, - RequestOptions, - ResolveErrorInput, + CreateApiClientConfig, + RequestOptions, + ResolveErrorInput, } from './api/createApiClient'; export { buildListQuery } from './api/query'; export { createErrorResolver } from './errors/createErrorResolver'; export type { - CreateErrorResolverConfig, - ErrorCatalog, - ResolveErrorMessageOptions, + CreateErrorResolverConfig, + ErrorCatalog, + ResolveErrorMessageOptions, } from './errors/createErrorResolver'; export { useValidatedFields } from './hooks/useValidatedFields'; @@ -33,9 +33,9 @@ export { useCooldownTimer } from './hooks/useCooldownTimer'; export { LeftMenuProvider, useLeftMenu } from './contexts/LeftMenuContext'; export type { - LeftMenuContent, - LeftMenuRenderState, - LeftMenuStyle, + LeftMenuContent, + LeftMenuRenderState, + LeftMenuStyle, } from './contexts/LeftMenuContext'; export { RightSidebarProvider, useRightSidebar } from './contexts/RightSidebarContext'; export type { RightSidebarContent, RightSidebarStyle } from './contexts/RightSidebarContext'; diff --git a/src/panels/useSidePanelMachine.ts b/src/panels/useSidePanelMachine.ts index dd638cc..6406283 100644 --- a/src/panels/useSidePanelMachine.ts +++ b/src/panels/useSidePanelMachine.ts @@ -1,9 +1,9 @@ import { - useCallback, - useEffect, - useRef, - useState, - type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, } from 'react'; const DEFAULT_DESKTOP_BREAKPOINT = 1024; @@ -11,176 +11,176 @@ const DEFAULT_DESKTOP_BREAKPOINT = 1024; type ResizeAxis = 'from-left' | 'from-right'; export type SidePanelMachineOptions = { - storageKey: string; - defaultWidth: number; - minWidth: number; - maxWidth: number; - resizeAxis: ResizeAxis; - resizingBodyClass: string; - isOpen: boolean; - canResize: boolean; - shouldPersistWidth: boolean; - closeOnPathname?: string; - onCloseOnPathname?: () => void; - onEscape?: () => void; - desktopBreakpoint?: number; + storageKey: string; + defaultWidth: number; + minWidth: number; + maxWidth: number; + resizeAxis: ResizeAxis; + resizingBodyClass: string; + isOpen: boolean; + canResize: boolean; + shouldPersistWidth: boolean; + closeOnPathname?: string; + onCloseOnPathname?: () => void; + onEscape?: () => void; + desktopBreakpoint?: number; }; export type SidePanelMachineResult = { - width: number; - isDesktop: boolean; - startResize: (event: ReactPointerEvent) => void; + width: number; + isDesktop: boolean; + startResize: (event: ReactPointerEvent) => void; }; export function isDesktopViewport(breakpoint = DEFAULT_DESKTOP_BREAKPOINT): boolean { - if (!globalThis.window) { - return true; - } + if (!globalThis.window) { + return true; + } - if (typeof globalThis.window.matchMedia === 'function') { - return globalThis.window.matchMedia(`(min-width: ${breakpoint}px)`).matches; - } + if (typeof globalThis.window.matchMedia === 'function') { + return globalThis.window.matchMedia(`(min-width: ${breakpoint}px)`).matches; + } - return window.innerWidth >= breakpoint; + return window.innerWidth >= breakpoint; } export function useSidePanelMachine({ - storageKey, - defaultWidth, - minWidth, - maxWidth, - resizeAxis, - resizingBodyClass, - isOpen, - canResize, - shouldPersistWidth, - closeOnPathname, - onCloseOnPathname, - onEscape, - desktopBreakpoint = DEFAULT_DESKTOP_BREAKPOINT, + storageKey, + defaultWidth, + minWidth, + maxWidth, + resizeAxis, + resizingBodyClass, + isOpen, + canResize, + shouldPersistWidth, + closeOnPathname, + onCloseOnPathname, + onEscape, + desktopBreakpoint = DEFAULT_DESKTOP_BREAKPOINT, }: SidePanelMachineOptions): SidePanelMachineResult { - const isResizingRef = useRef(false); - const resizeStartXRef = useRef(0); - const resizeStartWidthRef = useRef(0); + const isResizingRef = useRef(false); + const resizeStartXRef = useRef(0); + const resizeStartWidthRef = useRef(0); - const clampWidth = useCallback( - (value: number) => { - return Math.min(maxWidth, Math.max(minWidth, value)); - }, - [maxWidth, minWidth], - ); + const clampWidth = useCallback( + (value: number) => { + return Math.min(maxWidth, Math.max(minWidth, value)); + }, + [maxWidth, minWidth], + ); - const readStoredWidth = useCallback(() => { - if (!globalThis.window) { - return defaultWidth; - } + const readStoredWidth = useCallback(() => { + if (!globalThis.window) { + return defaultWidth; + } - const storedValue = localStorage.getItem(storageKey); - const parsed = Number(storedValue); - if (!Number.isFinite(parsed)) { - return defaultWidth; - } + const storedValue = localStorage.getItem(storageKey); + const parsed = Number(storedValue); + if (!Number.isFinite(parsed)) { + return defaultWidth; + } - return clampWidth(parsed); - }, [defaultWidth, storageKey, clampWidth]); + return clampWidth(parsed); + }, [defaultWidth, storageKey, clampWidth]); - const [width, setWidth] = useState(() => readStoredWidth()); + const [width, setWidth] = useState(() => readStoredWidth()); - useEffect(() => { - if (closeOnPathname == null || !onCloseOnPathname) { - return; - } + useEffect(() => { + if (closeOnPathname == null || !onCloseOnPathname) { + return; + } - onCloseOnPathname(); - }, [closeOnPathname, onCloseOnPathname]); + onCloseOnPathname(); + }, [closeOnPathname, onCloseOnPathname]); - useEffect(() => { - if (!shouldPersistWidth || !globalThis.window) { - return; - } + useEffect(() => { + if (!shouldPersistWidth || !globalThis.window) { + return; + } - localStorage.setItem(storageKey, String(width)); - }, [shouldPersistWidth, storageKey, width]); + localStorage.setItem(storageKey, String(width)); + }, [shouldPersistWidth, storageKey, width]); - useEffect(() => { - function handlePointerMove(event: PointerEvent) { - if (!isResizingRef.current || !canResize) { - return; - } + useEffect(() => { + function handlePointerMove(event: PointerEvent) { + if (!isResizingRef.current || !canResize) { + return; + } - const deltaX = event.clientX - resizeStartXRef.current; - const delta = resizeAxis === 'from-left' ? deltaX : -deltaX; - const nextWidth = clampWidth(resizeStartWidthRef.current + delta); - setWidth(nextWidth); - } + const deltaX = event.clientX - resizeStartXRef.current; + const delta = resizeAxis === 'from-left' ? deltaX : -deltaX; + const nextWidth = clampWidth(resizeStartWidthRef.current + delta); + setWidth(nextWidth); + } - function stopResizing() { - if (!isResizingRef.current) { - return; - } + function stopResizing() { + if (!isResizingRef.current) { + return; + } - isResizingRef.current = false; - document.body.classList.remove(resizingBodyClass); - } + isResizingRef.current = false; + document.body.classList.remove(resizingBodyClass); + } - globalThis.addEventListener('pointermove', handlePointerMove); - globalThis.addEventListener('pointerup', stopResizing); + globalThis.addEventListener('pointermove', handlePointerMove); + globalThis.addEventListener('pointerup', stopResizing); - return () => { - globalThis.removeEventListener('pointermove', handlePointerMove); - globalThis.removeEventListener('pointerup', stopResizing); - document.body.classList.remove(resizingBodyClass); + return () => { + globalThis.removeEventListener('pointermove', handlePointerMove); + globalThis.removeEventListener('pointerup', stopResizing); + document.body.classList.remove(resizingBodyClass); + }; + }, [canResize, clampWidth, resizeAxis, resizingBodyClass]); + + useEffect(() => { + if (!isOpen || isDesktopViewport(desktopBreakpoint)) { + return; + } + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isOpen, desktopBreakpoint]); + + useEffect(() => { + if (!isOpen || !onEscape) { + return; + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onEscape(); + } + }; + + globalThis.addEventListener('keydown', handleEscape); + return () => { + globalThis.removeEventListener('keydown', handleEscape); + }; + }, [isOpen, onEscape]); + + const startResize = useCallback( + (event: ReactPointerEvent) => { + if (!canResize || !isDesktopViewport(desktopBreakpoint)) { + return; + } + + isResizingRef.current = true; + resizeStartXRef.current = event.clientX; + resizeStartWidthRef.current = width; + document.body.classList.add(resizingBodyClass); + event.preventDefault(); + }, + [canResize, desktopBreakpoint, resizingBodyClass, width], + ); + + return { + width, + isDesktop: isDesktopViewport(desktopBreakpoint), + startResize, }; - }, [canResize, clampWidth, resizeAxis, resizingBodyClass]); - - useEffect(() => { - if (!isOpen || isDesktopViewport(desktopBreakpoint)) { - return; - } - - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - - return () => { - document.body.style.overflow = previousOverflow; - }; - }, [isOpen, desktopBreakpoint]); - - useEffect(() => { - if (!isOpen || !onEscape) { - return; - } - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onEscape(); - } - }; - - globalThis.addEventListener('keydown', handleEscape); - return () => { - globalThis.removeEventListener('keydown', handleEscape); - }; - }, [isOpen, onEscape]); - - const startResize = useCallback( - (event: ReactPointerEvent) => { - if (!canResize || !isDesktopViewport(desktopBreakpoint)) { - return; - } - - isResizingRef.current = true; - resizeStartXRef.current = event.clientX; - resizeStartWidthRef.current = width; - document.body.classList.add(resizingBodyClass); - event.preventDefault(); - }, - [canResize, desktopBreakpoint, resizingBodyClass, width], - ); - - return { - width, - isDesktop: isDesktopViewport(desktopBreakpoint), - startResize, - }; } diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index eddbf25..097b608 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -1,46 +1,48 @@ export function formatDate(value: string, seconds = false): string { - const options: Intl.DateTimeFormatOptions = { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - ...(seconds ? { second: '2-digit' } : {}), - }; + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + ...(seconds ? { second: '2-digit' } : {}), + }; - return new Date(value).toLocaleString('it-IT', options); + return new Date(value).toLocaleString('it-IT', options); } export const capitalize = (str: string) => - str - .toLowerCase() - .split(' ') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); + str + .toLowerCase() + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); export type SplitMode = 'underscore' | 'camel' | 'auto'; /** Title-case a string while preserving short all-caps acronyms (e.g., XML) */ const toTitleCase = (s: string) => - s - .trim() - .toLowerCase() - .split(/\s+/) - .map((w) => (/^[A-Z]{2,4}$/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())) - .join(' '); + s + .trim() + .toLowerCase() + .split(/\s+/) + .map((w) => + /^[A-Z]{2,4}$/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(), + ) + .join(' '); const splitUnderscoreHyphen = (s: string) => s.replaceAll(/[_-]+/g, ' '); /** Insert spaces at camelCase boundaries and around digit/letter edges */ const splitCamel = (s: string) => - s - // fooBar -> foo Bar ; foo2D -> foo 2D - .replaceAll(/([a-z0-9])([A-Z])/g, '$1 $2') - // XMLHttp -> XML Http (acronym + word) - .replaceAll(/([A-Z])([A-Z][a-z])/g, '$1 $2') - // letter<->digit boundaries - .replaceAll(/([a-zA-Z])([0-9])/g, '$1 $2') - .replaceAll(/([0-9])([a-zA-Z])/g, '$1 $2'); + s + // fooBar -> foo Bar ; foo2D -> foo 2D + .replaceAll(/([a-z0-9])([A-Z])/g, '$1 $2') + // XMLHttp -> XML Http (acronym + word) + .replaceAll(/([A-Z])([A-Z][a-z])/g, '$1 $2') + // letter<->digit boundaries + .replaceAll(/([a-zA-Z])([0-9])/g, '$1 $2') + .replaceAll(/([0-9])([a-zA-Z])/g, '$1 $2'); /** * Split and capitalize either by underscores/hyphens or camelCase. @@ -50,14 +52,14 @@ const splitCamel = (s: string) => * - "auto": pick underscore if present, otherwise camel */ export function splitAndCapitalize(str?: string, mode: SplitMode = 'auto'): string { - if (!str) return ''; + if (!str) return ''; - // normalize underscores/hyphens first for auto decision - const hasUnderscoreLike = /[_-]/.test(str); - const chosen: SplitMode = mode === 'auto' ? (hasUnderscoreLike ? 'underscore' : 'camel') : mode; + // normalize underscores/hyphens first for auto decision + const hasUnderscoreLike = /[_-]/.test(str); + const chosen: SplitMode = mode === 'auto' ? (hasUnderscoreLike ? 'underscore' : 'camel') : mode; - const spaced = chosen === 'underscore' ? splitUnderscoreHyphen(str) : splitCamel(str); + const spaced = chosen === 'underscore' ? splitUnderscoreHyphen(str) : splitCamel(str); - // collapse extra spaces, then title-case - return toTitleCase(spaced.replaceAll(/\s+/g, ' ').trim()); + // collapse extra spaces, then title-case + return toTitleCase(spaced.replaceAll(/\s+/g, ' ').trim()); } diff --git a/src/utils/verifiedEmail.ts b/src/utils/verifiedEmail.ts index 3da105e..45916e0 100644 --- a/src/utils/verifiedEmail.ts +++ b/src/utils/verifiedEmail.ts @@ -1,20 +1,20 @@ type VerifiedEmailVisibilityOptions = { - verifiedAt: string | null; - persistedEmail: string; - currentEmail: string; - isEditing: boolean; + verifiedAt: string | null; + persistedEmail: string; + currentEmail: string; + isEditing: boolean; }; export function shouldShowVerifiedEmailBadge(options: VerifiedEmailVisibilityOptions): boolean { - const { verifiedAt, persistedEmail, currentEmail, isEditing } = options; + const { verifiedAt, persistedEmail, currentEmail, isEditing } = options; - if (!verifiedAt) { - return false; - } + if (!verifiedAt) { + return false; + } - if (!isEditing) { - return true; - } + if (!isEditing) { + return true; + } - return persistedEmail.trim() === currentEmail.trim(); + return persistedEmail.trim() === currentEmail.trim(); } diff --git a/tsconfig.build.json b/tsconfig.build.json index 31cff07..d766ee6 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,12 +1,12 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true, - "rootDir": "src", - "outDir": "dist", - "declarationMap": true - }, - "include": ["src"] + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist", + "declarationMap": true + }, + "include": ["src"] } diff --git a/tsconfig.json b/tsconfig.json index d04ea12..afd05ea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,17 +1,17 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "isolatedModules": true, - "allowImportingTsExtensions": false, - "noEmit": true, - "types": ["react"] - }, - "include": ["src"] + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": false, + "noEmit": true, + "types": ["react"] + }, + "include": ["src"] } diff --git a/vite.config.ts b/vite.config.ts index 8075aa9..a8fa1f0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,16 +3,16 @@ import react from '@vitejs/plugin-react'; import { resolve } from 'node:path'; export default defineConfig({ - plugins: [react()], - build: { - lib: { - entry: resolve(__dirname, 'src/index.ts'), - name: 'PanicCore', - formats: ['es'], - fileName: () => 'index.js', + plugins: [react()], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'PanicCore', + formats: ['es'], + fileName: () => 'index.js', + }, + rollupOptions: { + external: ['react'], + }, }, - rollupOptions: { - external: ['react'], - }, - }, });