first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
import {MetaTag} from '@common/seo/meta-tag';
export interface BackendResponse {
status?: string;
seo?: MetaTag[];
// whether seo tags were already set with initial response from server for this data
set_seo?: boolean;
}

View File

@@ -0,0 +1,75 @@
import {BackendResponse} from './backend-response';
export interface LengthAwarePaginationResponse<T = unknown> {
data: T[];
from: number;
to: number;
total: number;
per_page: number;
current_page: number;
last_page: number;
next_page?: number;
prev_page?: number;
}
export interface SimplePaginationResponse<T = unknown> {
data: T[];
from: number;
to: number;
per_page: number;
current_page: number;
next_page?: number | null;
prev_page?: number | null;
}
interface CursorPaginationResponse<T> {
data: T[];
next_cursor: string | null;
per_page: number;
prev_cursor: string | null;
}
export type PaginationResponse<T> =
| LengthAwarePaginationResponse<T>
| SimplePaginationResponse<T>
| CursorPaginationResponse<T>;
export const EMPTY_PAGINATION_RESPONSE = {
pagination: {data: [], from: 0, to: 0, per_page: 15, current_page: 1},
};
export interface PaginatedBackendResponse<T> extends BackendResponse {
pagination: PaginationResponse<T>;
}
export function hasPreviousPage(
pagination: PaginationResponse<unknown>,
): boolean {
if ('prev_cursor' in pagination) {
return pagination.prev_cursor != null;
}
if ('prev_page' in pagination) {
return pagination.prev_page != null;
}
return pagination.current_page > 1;
}
export function hasNextPage(pagination: PaginationResponse<unknown>): boolean {
if ('next_cursor' in pagination) {
return pagination.next_cursor != null;
}
if ('last_page' in pagination) {
return pagination.current_page < pagination.last_page;
}
if ('next_page' in pagination) {
return pagination.next_page != null;
}
return (
pagination.data.length > 0 && pagination.data.length >= pagination.per_page
);
}

View File

@@ -0,0 +1,19 @@
import {UseQueryResult} from '@tanstack/react-query';
import {Helmet} from '@common/seo/helmet';
import {DefaultMetaTags} from '@common/seo/default-meta-tags';
import React from 'react';
import {BackendResponse} from '@common/http/backend-response/backend-response';
interface Props {
query: UseQueryResult<BackendResponse>;
}
export function PageMetaTags({query}: Props) {
if (query.data?.set_seo) {
return null;
}
return query.data?.seo ? (
<Helmet tags={query.data.seo} />
) : (
<DefaultMetaTags />
);
}

View File

@@ -0,0 +1,63 @@
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
import {errorStatusIs} from '@common/utils/http/error-status-is';
import {NotFoundPage} from '@common/ui/not-found-page/not-found-page';
import {PageErrorMessage} from '@common/errors/page-error-message';
import React, {ReactNode} from 'react';
import {UseQueryResult} from '@tanstack/react-query';
import {Navigate} from 'react-router-dom';
import {useAuth} from '@common/auth/use-auth';
import useSpinDelay from '@common/utils/hooks/use-spin-delay';
interface Props {
query: UseQueryResult;
show404?: boolean;
redirectOn404?: string;
loaderClassName?: string;
loaderIsScreen?: boolean;
loader?: ReactNode;
delayedSpinner?: boolean;
}
export function PageStatus({
query,
show404 = true,
loader,
loaderClassName,
loaderIsScreen = true,
delayedSpinner = true,
redirectOn404,
}: Props) {
const {isLoggedIn} = useAuth();
const showSpinner = useSpinDelay(query.isLoading, {
delay: 500,
minDuration: 200,
});
if (query.isLoading) {
if (!showSpinner && delayedSpinner) {
return null;
}
return (
loader || (
<FullPageLoader className={loaderClassName} screen={loaderIsScreen} />
)
);
}
if (
query.isError &&
(errorStatusIs(query.error, 401) || errorStatusIs(query.error, 403)) &&
!isLoggedIn
) {
return <Navigate to="/login" replace />;
}
if (show404 && query.isError && errorStatusIs(query.error, 404)) {
if (redirectOn404) {
return <Navigate to={redirectOn404} replace />;
}
return <NotFoundPage />;
}
return <PageErrorMessage />;
}

View File

@@ -0,0 +1,87 @@
import {QueryClient} from '@tanstack/react-query';
import axios, {AxiosRequestConfig} from 'axios';
import {getActiveWorkspaceId} from '../workspace/active-workspace-id';
import {isAbsoluteUrl} from '../utils/urls/is-absolute-url';
import {errorStatusIs} from '@common/utils/http/error-status-is';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30000,
retry: (failureCount, err) => {
return (
!errorStatusIs(err, 401) &&
!errorStatusIs(err, 403) &&
!errorStatusIs(err, 404) &&
failureCount < 2
);
},
},
},
});
export const apiClient = axios.create();
apiClient.defaults.withCredentials = true;
apiClient.defaults.responseType = 'json';
// @ts-ignore
apiClient.defaults.headers = {
common: {
Accept: 'application/json',
},
};
// @ts-ignore
apiClient.interceptors.request.use((config: AxiosRequestConfig) => {
if (
!config.url?.startsWith('auth') &&
!config.url?.startsWith('secure') &&
!config.url?.startsWith('log-viewer') &&
!isAbsoluteUrl(config?.url)
) {
config.url = `api/v1/${config.url}`;
}
const method = config.method?.toUpperCase();
// transform array query params in GET request to comma separated string
if (method === 'GET' && Array.isArray(config.params?.with)) {
config.params.with = config.params.with.join(',');
}
if (method === 'GET' && Array.isArray(config.params?.load)) {
config.params.load = config.params.load.join(',');
}
if (method === 'GET' && Array.isArray(config.params?.loadCount)) {
config.params.loadCount = config.params.loadCount.join(',');
}
// add workspace query parameter
const workspaceId = getActiveWorkspaceId();
if (workspaceId) {
const method = config.method?.toLowerCase();
if (['get', 'post', 'put'].includes(method!)) {
config.params = {...config.params, workspaceId};
}
}
// override PUT, DELETE, PATCH methods, they might not be supported on the backend
if (method === 'PUT' || method === 'DELETE' || method === 'PATCH') {
config.headers = {
...config.headers,
'X-HTTP-Method-Override': method,
};
config.method = 'POST';
config.params = {
...config.params,
_method: method,
};
}
if (import.meta.env.SSR) {
config.headers = {
...config.headers,
referer: 'http://localhost',
};
}
return config;
});

View File

@@ -0,0 +1,111 @@
import {keepPreviousData, useQuery} from '@tanstack/react-query';
import {BackendResponse} from './backend-response/backend-response';
import {Localization} from '../i18n/localization';
import {CssTheme} from '../ui/themes/css-theme';
import {Role} from '../auth/role';
import {Permission} from '../auth/permission';
import {apiClient, queryClient} from './query-client';
import {MenuItemCategory} from '../admin/appearance/sections/menus/menu-item-category';
import {CustomPage} from '../admin/custom-pages/custom-page';
import {CustomDomain} from '../custom-domains/custom-domain';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
export interface FetchValueListsResponse extends BackendResponse {
countries?: CountryListItem[];
timezones?: {[key: string]: Timezone[]};
languages?: LanguageListItem[];
localizations?: Localization[];
currencies?: {[key: string]: Currency};
domains?: CustomDomain[];
pages?: CustomPage[];
themes?: CssTheme[];
permissions?: Permission[];
workspacePermissions?: Permission[];
roles?: Role[];
menuItemCategories?: MenuItemCategory[];
googleFonts?: FontConfig[];
workspaceRoles?: Role[];
}
export interface CountryListItem {
name: string;
code: string;
}
export interface LanguageListItem {
name: string;
nativeName?: string;
code: string;
}
export interface Currency {
name: string;
decimal_digits: number;
symbol: string;
code: string;
}
export interface Timezone {
text: string;
value: string;
}
export interface FontConfig {
label?: MessageDescriptor;
family: string;
category?: string;
google?: boolean;
}
interface Options {
disabled?: boolean;
}
export function useValueLists(
names: (keyof FetchValueListsResponse)[],
params?: Record<string, string | number | undefined>,
options: Options = {},
) {
return useQuery({
queryKey: ['value-lists', names, params],
queryFn: () => fetchValueLists(names, params),
// if there are params, make sure we update lists when they change
staleTime: !params ? Infinity : undefined,
placeholderData: keepPreviousData,
enabled: !options.disabled,
initialData: () => {
// check if we have already fetched value lists for all specified names previously,
// if so, return cached response for this query, as there's no need to fetch it again
const previousData = queryClient
.getQueriesData<FetchValueListsResponse>({queryKey: ['ValueLists']})
.find(([, response]) => {
if (response && names.every(n => response[n])) {
return response;
}
return null;
});
if (previousData) {
return previousData[1];
}
},
});
}
export function prefetchValueLists(
names: (keyof FetchValueListsResponse)[],
params?: Record<string, string | number | undefined>,
) {
queryClient.prefetchQuery({
queryKey: ['value-lists', names, params],
queryFn: () => fetchValueLists(names, params),
});
}
function fetchValueLists(
names: (keyof FetchValueListsResponse)[],
params?: Record<string, string | number | undefined>,
): Promise<FetchValueListsResponse> {
return apiClient
.get(`value-lists/${names}`, {params})
.then(response => response.data);
}