@tiny-server/core 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/api/apiResults.d.ts +48 -0
  2. package/dist/api/entity.d.ts +27 -0
  3. package/dist/api/index.d.ts +8 -0
  4. package/dist/api/paginationOptions.d.ts +13 -0
  5. package/dist/api/permissions.d.ts +55 -0
  6. package/dist/api/problemDetails.d.ts +16 -0
  7. package/dist/api/session.d.ts +38 -0
  8. package/dist/api/sortOptions.d.ts +17 -0
  9. package/dist/api/status.d.ts +27 -0
  10. package/dist/app/App.d.ts +12 -0
  11. package/dist/app/context.d.ts +6 -0
  12. package/dist/app/create.d.ts +12 -0
  13. package/dist/app/features/extensions/Extension.d.ts +32 -0
  14. package/dist/app/features/extensions/index.d.ts +2 -0
  15. package/dist/app/features/extensions/types.d.ts +43 -0
  16. package/dist/app/features/index.d.ts +6 -0
  17. package/dist/app/features/layout/Error.d.ts +15 -0
  18. package/dist/app/features/layout/Layout.d.ts +4 -0
  19. package/dist/app/features/layout/Loading.d.ts +14 -0
  20. package/dist/app/features/layout/Page.d.ts +6 -0
  21. package/dist/app/features/layout/index.d.ts +6 -0
  22. package/dist/app/features/layout/initializer.d.ts +2 -0
  23. package/dist/app/features/layout/types.d.ts +13 -0
  24. package/dist/app/features/menu/Menu.d.ts +7 -0
  25. package/dist/app/features/menu/MenuContainer.d.ts +4 -0
  26. package/dist/app/features/menu/MenuItem.d.ts +11 -0
  27. package/dist/app/features/menu/index.d.ts +5 -0
  28. package/dist/app/features/menu/initializer.d.ts +2 -0
  29. package/dist/app/features/menu/types.d.ts +71 -0
  30. package/dist/app/features/modals/Modal.d.ts +5 -0
  31. package/dist/app/features/modals/ModalBody.d.ts +4 -0
  32. package/dist/app/features/modals/ModalContent.d.ts +7 -0
  33. package/dist/app/features/modals/ModalFooter.d.ts +5 -0
  34. package/dist/app/features/modals/ModalHeader.d.ts +5 -0
  35. package/dist/app/features/modals/Modals.d.ts +4 -0
  36. package/dist/app/features/modals/index.d.ts +8 -0
  37. package/dist/app/features/modals/initializer.d.ts +2 -0
  38. package/dist/app/features/modals/store.d.ts +14 -0
  39. package/dist/app/features/modals/types.d.ts +84 -0
  40. package/dist/app/features/query/AppQueryClientProvider.d.ts +2 -0
  41. package/dist/app/features/query/client.d.ts +2 -0
  42. package/dist/app/features/query/index.d.ts +3 -0
  43. package/dist/app/features/query/initializer.d.ts +2 -0
  44. package/dist/app/features/query/types.d.ts +9 -0
  45. package/dist/app/features/router/AppLayout.d.ts +1 -0
  46. package/dist/app/features/router/AppRouteLayout.d.ts +5 -0
  47. package/dist/app/features/router/AppRouter.d.ts +4 -0
  48. package/dist/app/features/router/RouterErrorBoundary.d.ts +1 -0
  49. package/dist/app/features/router/context.d.ts +5 -0
  50. package/dist/app/features/router/index.d.ts +3 -0
  51. package/dist/app/features/router/middlewares.d.ts +42 -0
  52. package/dist/app/index.d.ts +5 -0
  53. package/dist/app/types.d.ts +78 -0
  54. package/dist/components/ErrorBoundary.d.ts +19 -0
  55. package/dist/components/WithPermissions.d.ts +30 -0
  56. package/dist/components/index.d.ts +2 -0
  57. package/dist/fetch.d.ts +84 -0
  58. package/dist/i18n/InitI18n.d.ts +6 -0
  59. package/dist/i18n/defineLocales.d.ts +10 -0
  60. package/dist/i18n/index.d.ts +3 -0
  61. package/dist/i18n/useGlobalT.d.ts +7 -0
  62. package/dist/index.d.ts +7 -0
  63. package/dist/index.js +557 -0
  64. package/dist/module.d.ts +7 -0
  65. package/dist/utils/dates.d.ts +3 -0
  66. package/dist/utils/index.d.ts +4 -0
  67. package/dist/utils/usePagination.d.ts +31 -0
  68. package/dist/utils/useSearch.d.ts +19 -0
  69. package/dist/utils/useSession.d.ts +22 -0
  70. package/package.json +52 -0
  71. package/src/api/apiResults.ts +50 -0
  72. package/src/api/entity.ts +29 -0
  73. package/src/api/index.ts +8 -0
  74. package/src/api/paginationOptions.ts +13 -0
  75. package/src/api/permissions.ts +63 -0
  76. package/src/api/problemDetails.ts +19 -0
  77. package/src/api/session.ts +51 -0
  78. package/src/api/sortOptions.ts +18 -0
  79. package/src/api/status.ts +34 -0
  80. package/src/app/App.tsx +33 -0
  81. package/src/app/context.ts +17 -0
  82. package/src/app/create.ts +34 -0
  83. package/src/app/features/extensions/Extension.tsx +69 -0
  84. package/src/app/features/extensions/index.ts +2 -0
  85. package/src/app/features/extensions/types.tsx +52 -0
  86. package/src/app/features/index.ts +6 -0
  87. package/src/app/features/layout/Error.tsx +20 -0
  88. package/src/app/features/layout/Layout.tsx +7 -0
  89. package/src/app/features/layout/Loading.tsx +21 -0
  90. package/src/app/features/layout/Page.tsx +42 -0
  91. package/src/app/features/layout/index.ts +6 -0
  92. package/src/app/features/layout/initializer.ts +12 -0
  93. package/src/app/features/layout/types.ts +14 -0
  94. package/src/app/features/menu/Menu.tsx +66 -0
  95. package/src/app/features/menu/MenuContainer.tsx +9 -0
  96. package/src/app/features/menu/MenuItem.tsx +46 -0
  97. package/src/app/features/menu/index.ts +5 -0
  98. package/src/app/features/menu/initializer.ts +8 -0
  99. package/src/app/features/menu/types.ts +84 -0
  100. package/src/app/features/modals/Modal.tsx +20 -0
  101. package/src/app/features/modals/ModalBody.tsx +8 -0
  102. package/src/app/features/modals/ModalContent.tsx +35 -0
  103. package/src/app/features/modals/ModalFooter.tsx +19 -0
  104. package/src/app/features/modals/ModalHeader.tsx +15 -0
  105. package/src/app/features/modals/Modals.tsx +33 -0
  106. package/src/app/features/modals/index.ts +8 -0
  107. package/src/app/features/modals/initializer.ts +17 -0
  108. package/src/app/features/modals/store.ts +35 -0
  109. package/src/app/features/modals/types.ts +94 -0
  110. package/src/app/features/query/AppQueryClientProvider.tsx +7 -0
  111. package/src/app/features/query/client.ts +3 -0
  112. package/src/app/features/query/index.ts +3 -0
  113. package/src/app/features/query/initializer.ts +6 -0
  114. package/src/app/features/query/types.ts +10 -0
  115. package/src/app/features/router/AppLayout.tsx +18 -0
  116. package/src/app/features/router/AppRouteLayout.tsx +31 -0
  117. package/src/app/features/router/AppRouter.tsx +45 -0
  118. package/src/app/features/router/RouterErrorBoundary.tsx +20 -0
  119. package/src/app/features/router/context.ts +7 -0
  120. package/src/app/features/router/index.ts +3 -0
  121. package/src/app/features/router/middlewares.ts +76 -0
  122. package/src/app/index.ts +6 -0
  123. package/src/app/types.ts +82 -0
  124. package/src/components/ErrorBoundary.tsx +34 -0
  125. package/src/components/WithPermissions.tsx +59 -0
  126. package/src/components/index.ts +2 -0
  127. package/src/fetch.ts +185 -0
  128. package/src/i18n/InitI18n.tsx +24 -0
  129. package/src/i18n/defineLocales.ts +59 -0
  130. package/src/i18n/index.ts +3 -0
  131. package/src/i18n/useGlobalT.ts +13 -0
  132. package/src/index.ts +8 -0
  133. package/src/module.tsx +27 -0
  134. package/src/utils/dates.ts +34 -0
  135. package/src/utils/index.ts +4 -0
  136. package/src/utils/usePagination.ts +64 -0
  137. package/src/utils/useSearch.ts +40 -0
  138. package/src/utils/useSession.ts +42 -0
@@ -0,0 +1,7 @@
1
+ import { createContext } from 'react-router';
2
+ import type { AppInstance } from '../../types';
3
+
4
+ /**
5
+ * A router context which grants access to current {@link AppInstance}.
6
+ */
7
+ export const appContext = createContext<AppInstance>();
@@ -0,0 +1,3 @@
1
+ export * from './AppRouter';
2
+ export * from './context';
3
+ export * from './middlewares';
@@ -0,0 +1,76 @@
1
+ import { data, redirect, type MiddlewareFunction } from 'react-router';
2
+ import { appContext } from './context';
3
+ import { readSessionStateQuery, type Permission } from '../../../api';
4
+
5
+ export interface RequireSessionMiddlewareOptions {
6
+ /**
7
+ * The URL to which an anonymous user should be redirected to.
8
+ * @default '/'
9
+ */
10
+ redirectUrl?: string;
11
+ /**
12
+ * A set of permissions which are required to access the route.
13
+ */
14
+ permissions?: Array<Permission>;
15
+ /**
16
+ * A custom callback which allows custom handling of unauthorized access errors, i.e., situations
17
+ * where no active session exists.
18
+ * If not provided, the middleware will redirect to {@link redirectUrl}.
19
+ */
20
+ onUnauthorized?: () => void | Promise<void>;
21
+ /**
22
+ * A custom callback which allows custom handling of forbidden access errors, i.e., situations
23
+ * where the user session does not have the required permissions.
24
+ * If not provided, the middleware will throw a `403` data error.
25
+ */
26
+ onForbidden?: () => void | Promise<void>;
27
+ }
28
+
29
+ /**
30
+ * Creates a router middleware function which ensures that an active user session exists.
31
+ * If no session exists, the user is redirected to {@link RequireSessionMiddlewareOptions#redirectUrl}.
32
+ */
33
+ export function requireSession({
34
+ redirectUrl = '/',
35
+ permissions = [],
36
+ onUnauthorized,
37
+ onForbidden,
38
+ }: RequireSessionMiddlewareOptions = {}): MiddlewareFunction {
39
+ return async ({ context }) => {
40
+ const { queryClient } = context.get(appContext);
41
+ const { session } = await queryClient.ensureQueryData(readSessionStateQuery());
42
+
43
+ if (!session) {
44
+ await onUnauthorized?.();
45
+ throw redirect(redirectUrl);
46
+ }
47
+
48
+ if (!permissions.every((permission) => session.permissions.includes(permission))) {
49
+ await onForbidden?.();
50
+ throw data({ message: `Permission(s) '${permissions.join(', ')}' required` }, 403);
51
+ }
52
+ };
53
+ }
54
+
55
+ export interface RequireAnonymousMiddlewareOptions {
56
+ /**
57
+ * The URL to which a logged in user should be redirected to.
58
+ * @default '/'
59
+ */
60
+ redirectUrl?: string;
61
+ }
62
+
63
+ /**
64
+ * Creates a router middleware function which ensures that no user session exists.
65
+ * If a session exists, the user is redirected to {@link RequireAnonymousMiddlewareOptions#redirectUrl}.
66
+ */
67
+ export function requireAnonymous({ redirectUrl = '/' }: RequireAnonymousMiddlewareOptions = {}): MiddlewareFunction {
68
+ return async ({ context }) => {
69
+ const { queryClient } = context.get(appContext);
70
+ const { hasSession } = await queryClient.ensureQueryData(readSessionStateQuery());
71
+
72
+ if (hasSession) {
73
+ throw redirect(redirectUrl);
74
+ }
75
+ };
76
+ }
@@ -0,0 +1,6 @@
1
+ export * from './features';
2
+
3
+ export * from './App';
4
+ export { useApp } from './context';
5
+ export * from './create';
6
+ export * from './types';
@@ -0,0 +1,82 @@
1
+ import type { RouteObject } from 'react-router';
2
+ import type { AnyExtensionRegistration } from './features/extensions';
3
+ import type { AnyMenuItemRegistration } from './features/menu';
4
+ import type { AnyModalRegistration } from './features/modals';
5
+ import type { ComponentType, PropsWithChildren } from 'react';
6
+
7
+ /**
8
+ * Represents an instance of a configured application.
9
+ * {@link AppInstance} objects provide the app's central orchestration API.
10
+ */
11
+ export interface AppInstance {
12
+ /**
13
+ * The set of modules which make up this application instance.
14
+ */
15
+ modules: Array<Module>;
16
+ /**
17
+ * A set of application specific components.
18
+ */
19
+ components: AppComponents;
20
+ }
21
+
22
+ /**
23
+ * Represents a set of configurable components which can be declared by modules
24
+ * and used by any component that has access to the application instance.
25
+ *
26
+ * This interface can be extended by modules via declaration merging.
27
+ *
28
+ * ```ts
29
+ * declare module '@tiny-server/core' {
30
+ * export interface AppComponents {
31
+ * MyComponent: ComponentType<MyComponentProps>;
32
+ * }
33
+ * }
34
+ * ```
35
+ */
36
+ export interface AppComponents {}
37
+
38
+ /**
39
+ * An initializer function which can be registered as part of a module configuration.
40
+ *
41
+ * App initializers are called during app creation, receiving an {@link AppInstance}.
42
+ * They can perform any necessary setup or (re-)configuration of the app instance.
43
+ *
44
+ * App initializers *may* mutate the given app instance.
45
+ */
46
+ export type AppInitializer = (app: AppInstance) => AppInstance | void;
47
+
48
+ /**
49
+ * Initialization values for {@link Module} objects.
50
+ */
51
+ export type ModuleInit = Partial<Module>;
52
+
53
+ /**
54
+ * Represents an application module.
55
+ */
56
+ export interface Module {
57
+ /**
58
+ * Module-provided {@link AppInitializer} functions.
59
+ */
60
+ appInitializers: Array<AppInitializer>;
61
+ /**
62
+ * Module-provided provider components.
63
+ * These are rendered at the very top of the app's component tree.
64
+ */
65
+ providers: Array<ComponentType<PropsWithChildren>>;
66
+ /**
67
+ * Module-provided routes.
68
+ */
69
+ routes: Array<RouteObject>;
70
+ /**
71
+ * Module-provided extensions.
72
+ */
73
+ extensions: Array<AnyExtensionRegistration>;
74
+ /**
75
+ * Module-provided menu items.
76
+ */
77
+ menuItems: Array<AnyMenuItemRegistration>;
78
+ /**
79
+ * Module-provided modals.
80
+ */
81
+ modals: Array<AnyModalRegistration>;
82
+ }
@@ -0,0 +1,34 @@
1
+ import { Component, type PropsWithChildren, type ReactNode } from 'react';
2
+
3
+ interface State {
4
+ hasError: boolean;
5
+ }
6
+
7
+ export interface ErrorBoundaryProps extends PropsWithChildren {
8
+ /**
9
+ * The fallback to render when an error is caught.
10
+ */
11
+ fallback?: ReactNode;
12
+ }
13
+
14
+ /**
15
+ * A generic error boundary component which renders a fallback when an error is caught.
16
+ */
17
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
18
+ constructor(props: ErrorBoundaryProps) {
19
+ super(props);
20
+ this.state = { hasError: false };
21
+ }
22
+
23
+ static getDerivedStateFromError(): State {
24
+ return { hasError: true };
25
+ }
26
+
27
+ render() {
28
+ if (this.state.hasError) {
29
+ return this.props.fallback;
30
+ }
31
+
32
+ return this.props.children;
33
+ }
34
+ }
@@ -0,0 +1,59 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ import type { Permission } from '../api';
3
+ import { usePermissions } from '../utils';
4
+
5
+ export interface WithPermissionsProps {
6
+ /**
7
+ * The required permissions.
8
+ * @default []
9
+ */
10
+ permissions?: Array<Permission>;
11
+ /**
12
+ * The operator to use for checking permissions.
13
+ * @default 'and'
14
+ */
15
+ operator?: 'and' | 'or';
16
+ /**
17
+ * The content to render when the user has the required permissions.
18
+ */
19
+ children?: ReactNode;
20
+ /**
21
+ * The content to render when the user is lacking the required permissions.
22
+ */
23
+ fallback?: ReactNode;
24
+ }
25
+
26
+ /**
27
+ * Renders content depending on whether the users has one or more given permissions.
28
+ */
29
+ export function WithPermissions({ permissions = [], operator = 'and', children, fallback }: WithPermissionsProps) {
30
+ const userPermissions = usePermissions();
31
+ const hasPermissions =
32
+ operator === 'and'
33
+ ? permissions.every((permission) => userPermissions.includes(permission))
34
+ : permissions.some((permission) => userPermissions.includes(permission));
35
+
36
+ if (!hasPermissions) {
37
+ return <>{fallback}</>;
38
+ }
39
+
40
+ return <>{children}</>;
41
+ }
42
+
43
+ /**
44
+ * Wraps the given component in a {@link WithPermissions} component.
45
+ */
46
+ export function withPermissions<T extends object>(
47
+ withPermissionProps: Omit<WithPermissionsProps, 'children'>,
48
+ WrappedComponent: ComponentType<T>,
49
+ ): ComponentType<T> {
50
+ const hoc = function WithPermissionHoc(props: T) {
51
+ return (
52
+ <WithPermissions {...withPermissionProps}>
53
+ <WrappedComponent {...props} />
54
+ </WithPermissions>
55
+ );
56
+ };
57
+
58
+ return hoc;
59
+ }
@@ -0,0 +1,2 @@
1
+ export * from './ErrorBoundary';
2
+ export * from './WithPermissions';
package/src/fetch.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { isProblemDetails, type ProblemDetails } from './api';
2
+
3
+ /**
4
+ * Arguments for making an HTTP request using {@link appFetch}.
5
+ */
6
+ export interface AppFetchArgs<TResult = void> extends Omit<RequestInit, 'body'> {
7
+ /**
8
+ * The base URL to be used for the request.
9
+ * @default window.location.origin
10
+ */
11
+ baseUrl?: string;
12
+ /**
13
+ * The request URL.
14
+ */
15
+ url: string;
16
+ /**
17
+ * The body of the request.
18
+ * This body is, by default, automatically serialized as JSON **unless** the `Content-Type` header
19
+ * is set to a value other than `application/json`.
20
+ */
21
+ body?: BodyInit | object;
22
+ /**
23
+ * Additional query parameters to be appended to the URL.
24
+ * If set, these overwrite existing query parameters in {@link url} with the same name.
25
+ */
26
+ params?: AppFetchQueryParams;
27
+ /**
28
+ * Defines the handler which should be used to parse the response.
29
+ * @default 'json'
30
+ */
31
+ responseHandler?: DefaultResponseHandler | AppFetchResponseHandler<TResult>;
32
+ /**
33
+ * Defines the validators which should be used to validate the response.
34
+ * @default ['statusOk']
35
+ */
36
+ responseValidators?: Array<DefaultResponseValidator | AppFetchResponseValidator<TResult>>;
37
+ }
38
+
39
+ /**
40
+ * Supported query param types.
41
+ */
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ export type AppFetchQueryParams = Record<string, any>;
44
+
45
+ /**
46
+ * Represents a function which handles/processes a {@link Response} object.
47
+ * These handlers are supposed
48
+ */
49
+ export type AppFetchResponseHandler<TResult = void> = (response: Response) => Promise<TResult>;
50
+
51
+ /**
52
+ * Represents a function which validates that a response produced by {@link appFetch} is valid.
53
+ */
54
+ export type AppFetchResponseValidator<TResult = void> = (response: Response, result: TResult) => Promise<void>;
55
+
56
+ const defaultResponseHandlers = {
57
+ json: async (response) => {
58
+ const contentType = response.headers.get('content-type');
59
+ if (contentType && !/application\/(.+\+)?json/.test(contentType)) {
60
+ throw new Error(`JSON response handler received invalid content type "${contentType}".`);
61
+ }
62
+
63
+ const text = await response.text();
64
+ try {
65
+ return text.length ? JSON.parse(text) : null;
66
+ } catch {
67
+ throw new Error('JSON response handler failed to parse JSON response.');
68
+ }
69
+ },
70
+ } satisfies Record<string, AppFetchResponseHandler>;
71
+
72
+ const defaultResponseValidators = {
73
+ statusOk: async (response: Response, result: unknown) => {
74
+ if (!response.ok) {
75
+ if (isProblemDetails(result)) {
76
+ throw new ProblemDetailsError(response, result);
77
+ }
78
+
79
+ throw new AppFetchError(response, `Request failed with status code ${response.status}.`);
80
+ }
81
+ },
82
+ } satisfies Record<string, AppFetchResponseValidator>;
83
+
84
+ export type DefaultResponseHandler = keyof typeof defaultResponseHandlers;
85
+ export type DefaultResponseValidator = keyof typeof defaultResponseValidators;
86
+
87
+ /**
88
+ * A function similar to {@link fetch}, but tailored for the application's conventional backend API.
89
+ * {@link appFetch} implements automatic response (de-serialization) and validation.
90
+ * By default, {@link appFetch} assumes that requests and responses are based on JSON and that
91
+ * any non-2xx status code represents an error.
92
+ * These defaults can be overwritten via {@link args}.
93
+ * @param args The arguments for the fetch request. This can be a string representing the URL if no additional options are required.
94
+ */
95
+ export async function appFetch<TResult = void>(args: string | AppFetchArgs<TResult>): Promise<TResult> {
96
+ args = typeof args === 'string' ? { url: args } : args;
97
+ const {
98
+ baseUrl = window.location.origin,
99
+ url,
100
+ body,
101
+ params = {},
102
+ responseHandler = 'json',
103
+ responseValidators = ['statusOk'],
104
+ ...fetchInit
105
+ } = args;
106
+
107
+ const handleResponse =
108
+ typeof responseHandler === 'string' ? defaultResponseHandlers[responseHandler] : responseHandler;
109
+
110
+ const validateResponses = async (response: Response, result: TResult) => {
111
+ for (const validator of responseValidators) {
112
+ const validate = typeof validator === 'string' ? defaultResponseValidators[validator] : validator;
113
+ await validate(response, result);
114
+ }
115
+ };
116
+
117
+ const requestUrl = buildUrl(url, baseUrl, params);
118
+ const requestHeaders = new Headers(fetchInit.headers);
119
+ let requestBody: BodyInit | undefined;
120
+
121
+ if (
122
+ !(body instanceof FormData) &&
123
+ (!requestHeaders.has('Content-Type') || requestHeaders.get('Content-Type')?.includes('application/json'))
124
+ ) {
125
+ requestHeaders.set('Content-Type', 'application/json');
126
+ requestBody = JSON.stringify(body);
127
+ } else {
128
+ requestBody = body as BodyInit;
129
+ }
130
+
131
+ const requestInit: RequestInit = {
132
+ ...fetchInit,
133
+ headers: requestHeaders,
134
+ body: requestBody,
135
+ };
136
+
137
+ const response = await fetch(requestUrl, requestInit);
138
+ const result = await handleResponse(response);
139
+ await validateResponses(response, result);
140
+ return result;
141
+ }
142
+
143
+ function buildUrl(requestUrl: string, baseUrl: string, params: AppFetchQueryParams): URL {
144
+ const url = new URL(requestUrl, baseUrl);
145
+ const searchParams = url.searchParams;
146
+
147
+ for (const [key, value] of Object.entries(params)) {
148
+ const paramValues = Array.isArray(value) ? value : [value];
149
+
150
+ for (const paramValue of paramValues) {
151
+ if (paramValue !== undefined && paramValue !== null) {
152
+ searchParams.append(key, String(paramValue));
153
+ }
154
+ }
155
+ }
156
+
157
+ return url;
158
+ }
159
+
160
+ /**
161
+ * An error thrown by {@link appFetch} when a default validation fails.
162
+ */
163
+ export class AppFetchError extends Error {
164
+ /**
165
+ * The received fetch response.
166
+ */
167
+ readonly response: Response;
168
+
169
+ constructor(response: Response, message?: string | null) {
170
+ super(message ?? 'An error occurred during a fetch request.');
171
+ this.response = response;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * An error thrown by {@link appFetch} when the response contains a problem details object.
177
+ */
178
+ export class ProblemDetailsError extends AppFetchError {
179
+ readonly problem: Readonly<ProblemDetails>;
180
+
181
+ constructor(response: Response, problem: ProblemDetails) {
182
+ super(response, problem.title);
183
+ this.problem = problem;
184
+ }
185
+ }
@@ -0,0 +1,24 @@
1
+ import { type PropsWithChildren, useEffect } from 'react';
2
+ import i18n from 'i18next';
3
+ import { initReactI18next } from 'react-i18next';
4
+ import type { InitOptions } from 'i18next';
5
+
6
+ export interface InitI18nProps extends PropsWithChildren {
7
+ options?: InitOptions<unknown>;
8
+ }
9
+
10
+ const defaultOptions: InitOptions = {
11
+ lng: 'en',
12
+ fallbackLng: 'en',
13
+ interpolation: {
14
+ escapeValue: false,
15
+ },
16
+ };
17
+
18
+ export function InitI18n({ options = defaultOptions, children }: InitI18nProps) {
19
+ useEffect(() => {
20
+ i18n.use(initReactI18next).init(options);
21
+ }, [options]);
22
+
23
+ return children;
24
+ }
@@ -0,0 +1,59 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import i18next from 'i18next';
3
+
4
+ type KeyPrefixOrFn = string | ((...args: Array<any>) => any);
5
+
6
+ function makeKeyPrefix(keyPrefixOrFn: KeyPrefixOrFn | Array<KeyPrefixOrFn>) {
7
+ const stringify = (prefixOrFn: KeyPrefixOrFn) => (typeof prefixOrFn === 'string' ? prefixOrFn : prefixOrFn.name);
8
+
9
+ if (Array.isArray(keyPrefixOrFn)) {
10
+ // In the case of an array, we convert each array entry to the string representation.
11
+ // In addition, we add the current element index to the prefix.
12
+ // Why?
13
+ // Because i18n has issues if a parent prefix is used as part of the path. For example:
14
+ // Registration 1: parent
15
+ // Registration 2: parent.child
16
+ //
17
+ // Translations for parent.child will not be found. Using a different path for nested translations
18
+ // solves this.
19
+ // Applied to the example:
20
+ // Registration 1: parent
21
+ // Registration 2: parent-0.child
22
+ return keyPrefixOrFn.map((x, i) => stringify(x) + `-${i}`).join('.');
23
+ } else {
24
+ return stringify(keyPrefixOrFn);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Registers a translation resource bundle and creates a `useT` hook that returns a translation
30
+ * function which translates the registered keys.
31
+ * @param keyPrefixOrFn The key prefix or function that the translations are registered for.
32
+ * @param translations The set of translations to register, keyed by the language.
33
+ * @returns A `useT` hook that returns a {@link TFunction} that returns the registered translations.
34
+ */
35
+ export function defineLocales<T extends Record<string, Record<string, unknown>>>(
36
+ keyPrefixOrFn: KeyPrefixOrFn | Array<KeyPrefixOrFn>,
37
+ translations: T,
38
+ ) {
39
+ const keyPrefix = makeKeyPrefix(keyPrefixOrFn);
40
+
41
+ const registerResourceBundle = () => {
42
+ for (const [language, resources] of Object.entries(translations)) {
43
+ i18next?.addResourceBundle(language, 'translation', { [keyPrefix]: resources }, true, true);
44
+ }
45
+
46
+ console.info(`Registered translations for key "${keyPrefix}".`);
47
+ };
48
+
49
+ if (i18next.isInitialized) {
50
+ registerResourceBundle();
51
+ } else {
52
+ i18next.on('initialized', registerResourceBundle);
53
+ }
54
+
55
+ return function useT() {
56
+ const { t } = useTranslation(undefined, { keyPrefix });
57
+ return t;
58
+ };
59
+ }
@@ -0,0 +1,3 @@
1
+ export * from './defineLocales';
2
+ export * from './InitI18n';
3
+ export * from './useGlobalT';
@@ -0,0 +1,13 @@
1
+ import { useTranslation } from 'react-i18next';
2
+
3
+ /**
4
+ * Returns a non-scoped translation function.
5
+ * To be used for accessing global translations.
6
+ * If possible, use {@link defineLocales} instead.
7
+ * @param keyPrefix An optional key prefix.
8
+ */
9
+
10
+ export function useGlobalT(keyPrefix?: string) {
11
+ const { t } = useTranslation(undefined, { keyPrefix });
12
+ return t;
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './api';
2
+ export * from './app';
3
+ export * from './components';
4
+ export * from './utils';
5
+
6
+ export * from './fetch';
7
+ export * from './i18n/defineLocales';
8
+ export * from './module';
package/src/module.tsx ADDED
@@ -0,0 +1,27 @@
1
+ import type { InitOptions } from 'i18next';
2
+ import {
3
+ AppQueryClientProvider,
4
+ createModule,
5
+ layoutInitializer,
6
+ menuInitializer,
7
+ modalsInitializer,
8
+ queryInitializer,
9
+ } from './app';
10
+ import { MantineProvider, type MantineProviderProps } from '@mantine/core';
11
+ import { InitI18n } from './i18n';
12
+
13
+ export interface CoreModuleOptions {
14
+ i18n?: InitOptions<unknown>;
15
+ mantineProviderProps?: MantineProviderProps;
16
+ }
17
+
18
+ export function createCoreModule(options: CoreModuleOptions = {}) {
19
+ return createModule({
20
+ appInitializers: [layoutInitializer, menuInitializer, modalsInitializer, queryInitializer],
21
+ providers: [
22
+ (props) => <AppQueryClientProvider {...props} />,
23
+ (props) => <MantineProvider {...options.mantineProviderProps} {...props} />,
24
+ (props) => <InitI18n options={options.i18n} {...props} />,
25
+ ],
26
+ });
27
+ }
@@ -0,0 +1,34 @@
1
+ export function formatDate(date?: null | string | number | Date) {
2
+ return format(date, {
3
+ year: 'numeric',
4
+ month: '2-digit',
5
+ day: '2-digit',
6
+ });
7
+ }
8
+
9
+ export function formatTime(date?: null | string | number | Date) {
10
+ return format(date, {
11
+ hour: '2-digit',
12
+ minute: '2-digit',
13
+ second: '2-digit',
14
+ });
15
+ }
16
+
17
+ export function formatDateTime(date?: null | string | number | Date) {
18
+ return format(date, {
19
+ year: 'numeric',
20
+ month: '2-digit',
21
+ day: '2-digit',
22
+ hour: '2-digit',
23
+ minute: '2-digit',
24
+ second: '2-digit',
25
+ });
26
+ }
27
+
28
+ function format(date: undefined | null | string | number | Date, options: Intl.DateTimeFormatOptions) {
29
+ if (date === undefined || date === null) {
30
+ return undefined;
31
+ }
32
+
33
+ return new Date(date).toLocaleString(undefined, options);
34
+ }
@@ -0,0 +1,4 @@
1
+ export * from './dates';
2
+ export * from './usePagination';
3
+ export * from './useSearch';
4
+ export * from './useSession';