add eslint / prettier
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-23 14:18:51 +01:00
parent 4d1d2e6ed8
commit 33d1425fbb
24 changed files with 1294 additions and 398 deletions

View File

@@ -30,7 +30,7 @@ export class ApiError extends Error {
code,
requestId,
details,
rawMessage
rawMessage,
}: {
message: string;
status: number;
@@ -59,7 +59,7 @@ function parseErrorPayload(data: unknown) {
code: undefined as string | undefined,
rawMessage: undefined as string | undefined,
requestId: undefined as string | undefined,
details: undefined as unknown
details: undefined as unknown,
};
}
@@ -88,7 +88,7 @@ export function createApiClient(config: CreateApiClientConfig) {
baseUrl,
resolveError = defaultResolveError,
inferErrorCodeFromStatus,
fetchImpl
fetchImpl,
} = config;
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
@@ -99,9 +99,9 @@ export function createApiClient(config: CreateApiClientConfig) {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json().catch(() => null);
@@ -112,7 +112,7 @@ export function createApiClient(config: CreateApiClientConfig) {
const message = resolveError({
code,
status: response.status,
fallbackMessage: parsed.rawMessage
fallbackMessage: parsed.rawMessage,
});
throw new ApiError({
@@ -121,7 +121,7 @@ export function createApiClient(config: CreateApiClientConfig) {
code,
requestId: parsed.requestId,
details: parsed.details,
rawMessage: parsed.rawMessage
rawMessage: parsed.rawMessage,
});
}
@@ -129,6 +129,6 @@ export function createApiClient(config: CreateApiClientConfig) {
}
return {
request
request,
};
}

View File

@@ -11,7 +11,7 @@ export function buildListQuery({
page = 1,
pageSize = 10,
sort,
defaultSort
defaultSort,
}: BuildListQueryOptions): string {
const query = new URLSearchParams();
const normalizedQuery = q?.trim();

View File

@@ -41,18 +41,21 @@ export function createAuthContext<TUser>(options: CreateAuthContextOptions = {})
return {
authToken,
refreshToken,
currentUser: null
currentUser: null,
};
}
function AuthProvider({ children }: Readonly<{ children: ReactNode }>) {
const [state, setState] = useState<AuthState<TUser>>(readStoredSession);
const setSession = useCallback((authToken: string, refreshToken: string, currentUser: TUser) => {
localStorage.setItem(authTokenKey, authToken);
localStorage.setItem(refreshTokenKey, refreshToken);
setState({ authToken, refreshToken, currentUser });
}, []);
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);
@@ -64,12 +67,15 @@ export function createAuthContext<TUser>(options: CreateAuthContextOptions = {})
setState((prev) => ({ ...prev, currentUser }));
}, []);
const value = useMemo<AuthContextValue<TUser>>(() => ({
...state,
setSession,
setCurrentUser,
clearSession
}), [state, setSession, setCurrentUser, clearSession]);
const value = useMemo<AuthContextValue<TUser>>(
() => ({
...state,
setSession,
setCurrentUser,
clearSession,
}),
[state, setSession, setCurrentUser, clearSession],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
@@ -85,6 +91,6 @@ export function createAuthContext<TUser>(options: CreateAuthContextOptions = {})
return {
AuthProvider,
useAuth,
AuthContext
AuthContext,
};
}

View File

@@ -7,7 +7,7 @@ import {
useRef,
useState,
type CSSProperties,
type ReactNode
type ReactNode,
} from 'react';
import { isDesktopViewport, useSidePanelMachine } from '../panels/useSidePanelMachine';
@@ -68,7 +68,7 @@ function readStoredCollapsed(): boolean {
export function LeftMenuProvider({
children,
defaultContent,
closeOnPathname
closeOnPathname,
}: Readonly<LeftMenuProviderProps>) {
const [collapsed, setCollapsed] = useState<boolean>(() => readStoredCollapsed());
const [mobileOpen, setMobileOpen] = useState(false);
@@ -123,31 +123,37 @@ export function LeftMenuProvider({
closeMobile();
}, [collapseMenu, closeMobile]);
const openMenu = useCallback((nextContent?: LeftMenuContent) => {
if (nextContent) {
setContent(nextContent);
}
const openMenu = useCallback(
(nextContent?: LeftMenuContent) => {
if (nextContent) {
setContent(nextContent);
}
if (isDesktopViewport()) {
expandMenu();
return;
}
if (isDesktopViewport()) {
expandMenu();
return;
}
setMobileOpen(true);
}, [expandMenu]);
setMobileOpen(true);
},
[expandMenu],
);
const toggleMenu = useCallback((nextContent?: LeftMenuContent) => {
if (nextContent) {
setContent(nextContent);
}
const toggleMenu = useCallback(
(nextContent?: LeftMenuContent) => {
if (nextContent) {
setContent(nextContent);
}
if (isDesktopViewport()) {
toggleCollapsed();
return;
}
if (isDesktopViewport()) {
toggleCollapsed();
return;
}
setMobileOpen((previous) => !previous);
}, [toggleCollapsed]);
setMobileOpen((previous) => !previous);
},
[toggleCollapsed],
);
const handleCloseOnPathname = useCallback(() => {
setMobileOpen(false);
@@ -166,46 +172,48 @@ export function LeftMenuProvider({
shouldPersistWidth: !collapsed,
closeOnPathname,
onCloseOnPathname: handleCloseOnPathname,
onEscape: closeMobile
onEscape: closeMobile,
});
const desktopMenuStyle = useMemo<LeftMenuStyle>(() => ({
'--auth-sidebar-width': `${collapsed ? SIDEBAR_COLLAPSED_WIDTH : width}px`
}), [collapsed, width]);
const value = useMemo<LeftMenuContextValue>(() => ({
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 (
<LeftMenuContext.Provider value={value}>
{children}
</LeftMenuContext.Provider>
const desktopMenuStyle = useMemo<LeftMenuStyle>(
() => ({
'--auth-sidebar-width': `${collapsed ? SIDEBAR_COLLAPSED_WIDTH : width}px`,
}),
[collapsed, width],
);
const value = useMemo<LeftMenuContextValue>(
() => ({
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 <LeftMenuContext.Provider value={value}>{children}</LeftMenuContext.Provider>;
}
export function useLeftMenu() {

View File

@@ -5,7 +5,7 @@ import {
useMemo,
useState,
type CSSProperties,
type ReactNode
type ReactNode,
} from 'react';
import { isDesktopViewport, useSidePanelMachine } from '../panels/useSidePanelMachine';
@@ -46,7 +46,7 @@ const RightSidebarContext = createContext<RightSidebarContextValue | undefined>(
export function RightSidebarProvider({
children,
closeOnPathname,
onMobileOpenRequest
onMobileOpenRequest,
}: Readonly<RightSidebarProviderProps>) {
const [isOpen, setIsOpen] = useState(false);
const [content, setContent] = useState<RightSidebarContent | null>(null);
@@ -60,29 +60,35 @@ export function RightSidebarProvider({
setContent(nextContent);
}, []);
const openSidebar = useCallback((nextContent?: RightSidebarContent) => {
const resolvedContent = nextContent ?? content;
if (!resolvedContent) {
return;
}
const openSidebar = useCallback(
(nextContent?: RightSidebarContent) => {
const resolvedContent = nextContent ?? content;
if (!resolvedContent) {
return;
}
if (nextContent) {
setContent(nextContent);
}
if (!isDesktopViewport()) {
onMobileOpenRequest?.();
}
setIsOpen(true);
}, [content, onMobileOpenRequest]);
if (nextContent) {
setContent(nextContent);
}
if (!isDesktopViewport()) {
onMobileOpenRequest?.();
}
setIsOpen(true);
},
[content, onMobileOpenRequest],
);
const toggleSidebar = useCallback((nextContent?: RightSidebarContent) => {
if (isOpen) {
closeSidebar();
return;
}
const toggleSidebar = useCallback(
(nextContent?: RightSidebarContent) => {
if (isOpen) {
closeSidebar();
return;
}
openSidebar(nextContent);
}, [isOpen, closeSidebar, openSidebar]);
openSidebar(nextContent);
},
[isOpen, closeSidebar, openSidebar],
);
const { width, startResize } = useSidePanelMachine({
storageKey: RIGHT_SIDEBAR_WIDTH_KEY,
@@ -96,38 +102,40 @@ export function RightSidebarProvider({
shouldPersistWidth: true,
closeOnPathname,
onCloseOnPathname: closeSidebar,
onEscape: closeSidebar
onEscape: closeSidebar,
});
const desktopSidebarStyle = useMemo<RightSidebarStyle>(() => ({
'--auth-right-sidebar-width': `${width}px`
}), [width]);
const value = useMemo<RightSidebarContextValue>(() => ({
isOpen,
content,
openSidebar,
closeSidebar,
toggleSidebar,
setSidebarContent,
desktopSidebarStyle,
startResize
}), [
isOpen,
content,
openSidebar,
closeSidebar,
toggleSidebar,
setSidebarContent,
desktopSidebarStyle,
startResize
]);
return (
<RightSidebarContext.Provider value={value}>
{children}
</RightSidebarContext.Provider>
const desktopSidebarStyle = useMemo<RightSidebarStyle>(
() => ({
'--auth-right-sidebar-width': `${width}px`,
}),
[width],
);
const value = useMemo<RightSidebarContextValue>(
() => ({
isOpen,
content,
openSidebar,
closeSidebar,
toggleSidebar,
setSidebarContent,
desktopSidebarStyle,
startResize,
}),
[
isOpen,
content,
openSidebar,
closeSidebar,
toggleSidebar,
setSidebarContent,
desktopSidebarStyle,
startResize,
],
);
return <RightSidebarContext.Provider value={value}>{children}</RightSidebarContext.Provider>;
}
export function useRightSidebar() {

View File

@@ -30,7 +30,7 @@ export function createErrorResolver(config: CreateErrorResolverConfig) {
defaultContext = 'default',
contextOverrides = {},
inferCodeFromStatus,
inferCodeFromLegacyMessage
inferCodeFromLegacyMessage,
} = config;
const knownCodes = new Set(Object.keys(catalog));
@@ -51,16 +51,12 @@ export function createErrorResolver(config: CreateErrorResolverConfig) {
}
function resolveErrorMessage(options: ResolveErrorMessageOptions): string {
const {
code,
status,
context = defaultContext,
fallbackMessage
} = options;
const { code, status, context = defaultContext, fallbackMessage } = options;
const resolvedCode = normalizeErrorCode(code)
?? inferCodeFromLegacyMessage?.(fallbackMessage)
?? inferErrorCodeFromStatus(status);
const resolvedCode =
normalizeErrorCode(code) ??
inferCodeFromLegacyMessage?.(fallbackMessage) ??
inferErrorCodeFromStatus(status);
if (resolvedCode) {
const contextMessage = contextOverrides[context]?.[resolvedCode];
@@ -96,7 +92,10 @@ export function createErrorResolver(config: CreateErrorResolverConfig) {
return 'Request failed. Please try again.';
}
function resolveOptionalErrorMessage(code?: string | null, context: string = defaultContext): string | undefined {
function resolveOptionalErrorMessage(
code?: string | null,
context: string = defaultContext,
): string | undefined {
if (!code) {
return undefined;
}
@@ -108,14 +107,15 @@ export function createErrorResolver(config: CreateErrorResolverConfig) {
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 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
fallbackMessage: rawMessage ?? message,
});
}
@@ -127,6 +127,6 @@ export function createErrorResolver(config: CreateErrorResolverConfig) {
inferErrorCodeFromStatus,
resolveErrorMessage,
resolveOptionalErrorMessage,
toErrorMessage
toErrorMessage,
};
}

View File

@@ -23,6 +23,6 @@ export function useCooldownTimer(seconds = 0, enabled = true) {
return {
cooldown,
startCooldown
startCooldown,
};
}

View File

@@ -10,7 +10,7 @@ type UseEditableFormOptions<TValues> = {
export function useEditableForm<TValues extends Record<string, string>>({
initialValues,
validate
validate,
}: UseEditableFormOptions<TValues>) {
const [isEditing, setIsEditing] = useState(false);
@@ -23,30 +23,42 @@ export function useEditableForm<TValues extends Record<string, string>>({
validateAll,
setFieldError,
setErrors,
clearErrors
clearErrors,
} = useValidatedFields({
initialValues,
validate
validate,
});
const startEditing = useCallback((sourceValues: TValues) => {
setValues(sourceValues, { validate: true });
setIsEditing(true);
}, [setValues]);
const startEditing = useCallback(
(sourceValues: TValues) => {
setValues(sourceValues, { validate: true });
setIsEditing(true);
},
[setValues],
);
const discardChanges = useCallback((sourceValues: TValues) => {
setValues(sourceValues, { clearErrors: true });
setIsEditing(false);
}, [setValues]);
const discardChanges = useCallback(
(sourceValues: TValues) => {
setValues(sourceValues, { clearErrors: true });
setIsEditing(false);
},
[setValues],
);
const loadFromSource = useCallback((sourceValues: TValues) => {
setValues(sourceValues, { clearErrors: true });
}, [setValues]);
const loadFromSource = useCallback(
(sourceValues: TValues) => {
setValues(sourceValues, { clearErrors: true });
},
[setValues],
);
const commitSaved = useCallback((sourceValues: TValues) => {
setValues(sourceValues, { clearErrors: true });
setIsEditing(false);
}, [setValues]);
const commitSaved = useCallback(
(sourceValues: TValues) => {
setValues(sourceValues, { clearErrors: true });
setIsEditing(false);
},
[setValues],
);
return {
values,
@@ -63,6 +75,6 @@ export function useEditableForm<TValues extends Record<string, string>>({
discardChanges,
loadFromSource,
commitSaved,
setIsEditing
setIsEditing,
};
}

View File

@@ -9,7 +9,12 @@ type PaginatedResourceResponse<TItem> = {
};
type UsePaginatedResourceOptions<TItem> = {
load: (params: { q: string; page: number; pageSize: number; sort?: string }) => Promise<PaginatedResourceResponse<TItem>>;
load: (params: {
q: string;
page: number;
pageSize: number;
sort?: string;
}) => Promise<PaginatedResourceResponse<TItem>>;
sort?: string;
debounceMs?: number;
initialQuery?: string;
@@ -23,7 +28,7 @@ export function usePaginatedResource<TItem>({
debounceMs = 250,
initialQuery = '',
initialPage = 1,
initialPageSize = 10
initialPageSize = 10,
}: UsePaginatedResourceOptions<TItem>) {
const [items, setItems] = useState<TItem[]>([]);
const [q, setQ] = useState(initialQuery);
@@ -46,7 +51,7 @@ export function usePaginatedResource<TItem>({
q,
page,
pageSize,
sort
sort,
});
if (cancelled) {
@@ -97,6 +102,6 @@ export function usePaginatedResource<TItem>({
isLoading,
setQuery,
setPage,
setPageSize: setPageSizeAndResetPage
setPageSize: setPageSizeAndResetPage,
};
}

View File

@@ -31,32 +31,35 @@ export function useSorting(defaultSort?: SortState | null): UseSortingResult {
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 (baselineSort && baselineSort.field === field) {
if (previousOverride == null) {
return { field, direction: invertDirection(baselineSort.direction) };
if (!currentSort || currentSort.field !== field) {
return { field, direction: 'asc' };
}
if (previousOverride.direction === baselineSort.direction) {
return { field, direction: invertDirection(baselineSort.direction) };
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;
}
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);
@@ -73,6 +76,6 @@ export function useSorting(defaultSort?: SortState | null): UseSortingResult {
sortParam,
toggleSort,
setSort,
resetSort
resetSort,
};
}

View File

@@ -26,6 +26,6 @@ export function useSubmitState<TStatus = string | null>(initialStatus: TStatus)
finishSubmitting,
setSubmitError,
setStatus,
clearFeedback
clearFeedback,
};
}

View File

@@ -28,7 +28,7 @@ function hasErrors<TValues>(errors: FieldErrors<TValues>): boolean {
function pickTouchedErrors<TValues>(
errors: FieldErrors<TValues>,
touched: TouchedFields<TValues>
touched: TouchedFields<TValues>,
): FieldErrors<TValues> {
const next: FieldErrors<TValues> = {};
@@ -53,75 +53,80 @@ function touchAll<TValues extends Record<string, string>>(values: TValues): Touc
export function useValidatedFields<TValues extends Record<string, string>>({
initialValues,
validate
validate,
}: UseValidatedFieldsOptions<TValues>) {
const [values, setValues] = useState<TValues>(initialValues);
const [allErrors, setAllErrors] = useState<FieldErrors<TValues>>(() => validate(initialValues));
const [touched, setTouched] = useState<TouchedFields<TValues>>({});
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 (clearErrors) {
setTouched({});
}
}, [validate]);
const setFieldValue = useCallback(<K extends keyof TValues>(
key: K,
value: TValues[K],
options: SetFieldValueOptions = {}
) => {
const { validate: shouldValidate = true, touch = true } = options;
if (touch) {
setTouched((current) => ({
...current,
[key]: true
}));
}
setValues((current) => {
const nextValues = {
...current,
[key]: value
};
if (shouldValidate) {
if (shouldValidate || clearErrors) {
setAllErrors(validate(nextValues));
}
return nextValues;
});
}, [validate]);
if (clearErrors) {
setTouched({});
}
},
[validate],
);
const validateAll = useCallback((options: ValidateAllOptions = {}) => {
const { touchAll: shouldTouchAll = true } = options;
const nextErrors = validate(values);
const setFieldValue = useCallback(
<K extends keyof TValues>(key: K, value: TValues[K], options: SetFieldValueOptions = {}) => {
const { validate: shouldValidate = true, touch = true } = options;
setAllErrors(nextErrors);
if (touch) {
setTouched((current) => ({
...current,
[key]: true,
}));
}
if (shouldTouchAll) {
setTouched(touchAll(values));
}
setValues((current) => {
const nextValues = {
...current,
[key]: value,
};
return nextErrors;
}, [validate, values]);
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(<K extends keyof TValues>(key: K, message?: string) => {
setTouched((current) => ({
...current,
[key]: true
[key]: true,
}));
setAllErrors((current) => ({
...current,
[key]: message
[key]: message,
}));
}, []);
@@ -136,7 +141,7 @@ export function useValidatedFields<TValues extends Record<string, string>>({
setTouched((current) => ({
...current,
...nextTouched
...nextTouched,
}));
setAllErrors(nextErrors);
}, []);
@@ -161,6 +166,6 @@ export function useValidatedFields<TValues extends Record<string, string>>({
validateAll,
setFieldError,
setErrors: updateErrors,
clearErrors
clearErrors,
};
}

View File

@@ -1,15 +1,27 @@
export { createAuthContext } from './auth/createAuthContext';
export type { AuthContextValue, AuthState, CreateAuthContextOptions } from './auth/createAuthContext';
export type {
AuthContextValue,
AuthState,
CreateAuthContextOptions,
} from './auth/createAuthContext';
export { decodeJwtPayload, isJwtExpired } from './auth/jwt';
export { createApiClient, ApiError } from './api/createApiClient';
export type { CreateApiClientConfig, RequestOptions, ResolveErrorInput } from './api/createApiClient';
export type {
CreateApiClientConfig,
RequestOptions,
ResolveErrorInput,
} from './api/createApiClient';
export { buildListQuery } from './api/query';
export { createErrorResolver } from './errors/createErrorResolver';
export type { CreateErrorResolverConfig, ErrorCatalog, ResolveErrorMessageOptions } from './errors/createErrorResolver';
export type {
CreateErrorResolverConfig,
ErrorCatalog,
ResolveErrorMessageOptions,
} from './errors/createErrorResolver';
export { useValidatedFields } from './hooks/useValidatedFields';
export { useEditableForm } from './hooks/useEditableForm';
@@ -20,7 +32,11 @@ export type { SortDirection, SortState } from './hooks/useSorting';
export { useCooldownTimer } from './hooks/useCooldownTimer';
export { LeftMenuProvider, useLeftMenu } from './contexts/LeftMenuContext';
export type { LeftMenuContent, LeftMenuRenderState, LeftMenuStyle } from './contexts/LeftMenuContext';
export type {
LeftMenuContent,
LeftMenuRenderState,
LeftMenuStyle,
} from './contexts/LeftMenuContext';
export { RightSidebarProvider, useRightSidebar } from './contexts/RightSidebarContext';
export type { RightSidebarContent, RightSidebarStyle } from './contexts/RightSidebarContext';

View File

@@ -1,4 +1,10 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react';
const DEFAULT_DESKTOP_BREAKPOINT = 1024;
@@ -51,15 +57,18 @@ export function useSidePanelMachine({
closeOnPathname,
onCloseOnPathname,
onEscape,
desktopBreakpoint = DEFAULT_DESKTOP_BREAKPOINT
desktopBreakpoint = DEFAULT_DESKTOP_BREAKPOINT,
}: SidePanelMachineOptions): SidePanelMachineResult {
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) {
@@ -154,21 +163,24 @@ export function useSidePanelMachine({
};
}, [isOpen, onEscape]);
const startResize = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!canResize || !isDesktopViewport(desktopBreakpoint)) {
return;
}
const startResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
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]);
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
startResize,
};
}

View File

@@ -1,45 +1,46 @@
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";
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, " ");
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.
@@ -48,17 +49,15 @@ const splitCamel = (s: string) =>
* - "camel": split on camelCase boundaries
* - "auto": pick underscore if present, otherwise camel
*/
export function splitAndCapitalize(str?: string, mode: SplitMode = "auto"): string {
if (!str) return "";
export function splitAndCapitalize(str?: string, mode: SplitMode = 'auto'): string {
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());
}