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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,86 @@
import {useForm} from 'react-hook-form';
import {Dialog} from '../../ui/overlays/dialog/dialog';
import {DialogHeader} from '../../ui/overlays/dialog/dialog-header';
import {Trans} from '../../i18n/trans';
import {DialogBody} from '../../ui/overlays/dialog/dialog-body';
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
import {Form} from '../../ui/forms/form';
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
import {useValueLists} from '../../http/value-lists';
import {FormSelect, Option} from '../../ui/forms/select/select';
import {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';
import {Button} from '../../ui/buttons/button';
import {
CreateLocalizationPayload,
useCreateLocalization,
} from './create-localization';
import {message} from '@common/i18n/message';
import {useTrans} from '@common/i18n/use-trans';
export function CreateLocationDialog() {
const {trans} = useTrans();
const {formId, close} = useDialogContext();
const form = useForm<CreateLocalizationPayload>({
defaultValues: {
language: 'en',
},
});
const {data} = useValueLists(['languages']);
const languages = data?.languages || [];
const createLocalization = useCreateLocalization(form);
return (
<Dialog>
<DialogHeader>
<Trans message="Create localization" />
</DialogHeader>
<DialogBody>
<Form
form={form}
id={formId}
onSubmit={values => {
createLocalization.mutate(values, {onSuccess: close});
}}
>
<FormTextField
autoFocus
name="name"
label={<Trans message="Name" />}
className="mb-30"
required
/>
<FormSelect
required
name="language"
label={<Trans message="Language" />}
selectionMode="single"
showSearchField
searchPlaceholder={trans(message('Search languages'))}
>
{languages.map(language => (
<Option value={language.code} key={language.code}>
{language.name}
</Option>
))}
</FormSelect>
</Form>
</DialogBody>
<DialogFooter>
<Button onClick={close}>
<Trans message="Cancel" />
</Button>
<Button
variant="flat"
color="primary"
type="submit"
form={formId}
disabled={createLocalization.isPending}
>
<Trans message="Save" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,40 @@
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {UseFormReturn} from 'react-hook-form';
import {toast} from '../../ui/toast/toast';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {apiClient} from '../../http/query-client';
import {message} from '../../i18n/message';
import {DatatableDataQueryKey} from '../../datatable/requests/paginated-resources';
import {onFormQueryError} from '../../errors/on-form-query-error';
import {Localization} from '../../i18n/localization';
interface Response extends BackendResponse {
localization: Localization;
}
export interface CreateLocalizationPayload {
name: string;
language: string;
}
function createLocalization(
payload: CreateLocalizationPayload,
): Promise<Response> {
return apiClient.post(`localizations`, payload).then(r => r.data);
}
export function useCreateLocalization(
form: UseFormReturn<CreateLocalizationPayload>,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (props: CreateLocalizationPayload) => createLocalization(props),
onSuccess: () => {
toast(message('Localization created'));
queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('localizations'),
});
},
onError: r => onFormQueryError(r, form),
});
}

View File

@@ -0,0 +1,166 @@
import React, {Fragment} from 'react';
import {Link} from 'react-router-dom';
import {DataTablePage} from '../../datatable/page/data-table-page';
import {IconButton} from '../../ui/buttons/icon-button';
import {FormattedDate} from '../../i18n/formatted-date';
import {ColumnConfig} from '../../datatable/column-config';
import {Trans} from '../../i18n/trans';
import {Localization} from '../../i18n/localization';
import {TranslateIcon} from '../../icons/material/Translate';
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
import {UpdateLocalizationDialog} from './update-localization-dialog';
import {Tooltip} from '../../ui/tooltip/tooltip';
import {CreateLocationDialog} from './create-localization-dialog';
import {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';
import aroundTheWorldSvg from './around-the-world.svg';
import {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';
import {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';
import {
Menu,
MenuItem,
MenuTrigger,
} from '@common/ui/navigation/menu/menu-trigger';
import {openDialog} from '@common/ui/overlays/store/dialog-store';
import {downloadFileFromUrl} from '@common/uploads/utils/download-file-from-url';
import {MoreVertIcon} from '@common/icons/material/MoreVert';
import {UploadInputType} from '@common/uploads/types/upload-input-config';
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
import {useUploadTranslationFile} from '@common/admin/translations/use-upload-translation-file';
import {openUploadWindow} from '@common/uploads/utils/open-upload-window';
const columnConfig: ColumnConfig<Localization>[] = [
{
key: 'name',
allowsSorting: true,
sortingKey: 'name',
visibleInMode: 'all',
width: 'flex-3 min-w-200',
header: () => <Trans message="Name" />,
body: locale => locale.name,
},
{
key: 'language',
allowsSorting: true,
sortingKey: 'language',
header: () => <Trans message="Language code" />,
body: locale => locale.language,
},
{
key: 'updatedAt',
allowsSorting: true,
width: 'w-100',
header: () => <Trans message="Last updated" />,
body: locale => <FormattedDate date={locale.updated_at} />,
},
{
key: 'actions',
header: () => <Trans message="Actions" />,
hideHeader: true,
align: 'end',
width: 'w-84 flex-shrink-0',
visibleInMode: 'all',
body: locale => {
return (
<div className="text-muted">
<Tooltip label={<Trans message="Translate" />}>
<IconButton
size="md"
elementType={Link}
to={`${locale.id}/translate`}
>
<TranslateIcon />
</IconButton>
</Tooltip>
<FileUploadProvider>
<RowActionsMenuTrigger locale={locale} />
</FileUploadProvider>
</div>
);
},
},
];
export function LocalizationIndex() {
return (
<DataTablePage
endpoint="localizations"
title={<Trans message="Localizations" />}
columns={columnConfig}
actions={<Actions />}
selectedActions={<DeleteSelectedItemsAction />}
emptyStateMessage={
<DataTableEmptyStateMessage
image={aroundTheWorldSvg}
title={<Trans message="No localizations have been created yet" />}
filteringTitle={<Trans message="No matching localizations" />}
/>
}
/>
);
}
function Actions() {
return (
<Fragment>
<DialogTrigger type="modal">
<DataTableAddItemButton>
<Trans message="Add new localization" />
</DataTableAddItemButton>
<CreateLocationDialog />
</DialogTrigger>
</Fragment>
);
}
interface RowActionsMenuTriggerProps {
locale: Localization;
}
function RowActionsMenuTrigger({locale}: RowActionsMenuTriggerProps) {
const uploadFile = useUploadTranslationFile();
return (
<MenuTrigger>
<IconButton disabled={uploadFile.isPending}>
<MoreVertIcon />
</IconButton>
<Menu>
<MenuItem
value="translate"
elementType={Link}
to={`${locale.id}/translate`}
>
<Trans message="Translate" />
</MenuItem>
<MenuItem
value="rename"
onSelected={() =>
openDialog(UpdateLocalizationDialog, {localization: locale})
}
>
<Trans message="Rename" />
</MenuItem>
<MenuItem
value="download"
onSelected={() =>
downloadFileFromUrl(`api/v1/localizations/${locale.id}/download`)
}
>
<Trans message="Download" />
</MenuItem>
<MenuItem
value="upload"
onSelected={async () => {
const files = await openUploadWindow({
types: [UploadInputType.json],
});
if (files.length == 1) {
uploadFile.mutate({localeId: locale.id, file: files[0]});
}
}}
>
<Trans message="Upload" />
</MenuItem>
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,72 @@
import {useForm} from 'react-hook-form';
import {Dialog} from '../../ui/overlays/dialog/dialog';
import {DialogHeader} from '../../ui/overlays/dialog/dialog-header';
import {Trans} from '../../i18n/trans';
import {DialogBody} from '../../ui/overlays/dialog/dialog-body';
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
import {Form} from '../../ui/forms/form';
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
import {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';
import {Button} from '../../ui/buttons/button';
import {SectionHelper} from '../../ui/section-helper';
interface FormValue {
key: string;
value: string;
}
export function NewTranslationDialog() {
const {formId, close} = useDialogContext();
const form = useForm<FormValue>();
return (
<Dialog>
<DialogHeader>
<Trans message="Add translation" />
</DialogHeader>
<DialogBody>
<Form
form={form}
id={formId}
onSubmit={values => {
close(values);
}}
>
<SectionHelper
className="mb-30"
title={
<Trans message="Add a new translation, if it does not exist already." />
}
description={
<Trans message="This should only need to be done for things like custom menu items." />
}
/>
<FormTextField
inputElementType="textarea"
rows={2}
autoFocus
name="key"
label={<Trans message="Translation key" />}
className="mb-30"
required
/>
<FormTextField
inputElementType="textarea"
rows={2}
name="value"
label={<Trans message="Translation value" />}
required
/>
</Form>
</DialogBody>
<DialogFooter>
<Button onClick={close}>
<Trans message="Cancel" />
</Button>
<Button variant="flat" color="primary" type="submit" form={formId}>
<Trans message="Add" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,298 @@
import React, {useMemo, useRef, useState} from 'react';
import {useParams} from 'react-router-dom';
import {useLocaleWithLines} from './use-locale-with-lines';
import {Trans} from '../../i18n/trans';
import {IconButton} from '../../ui/buttons/icon-button';
import {Button} from '../../ui/buttons/button';
import {Breadcrumb} from '../../ui/breadcrumbs/breadcrumb';
import {BreadcrumbItem} from '../../ui/breadcrumbs/breadcrumb-item';
import {TextField} from '../../ui/forms/input-field/text-field/text-field';
import {useTrans} from '../../i18n/use-trans';
import {SearchIcon} from '../../icons/material/Search';
import {CloseIcon} from '../../icons/material/Close';
import {AddIcon} from '../../icons/material/Add';
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
import {NewTranslationDialog} from './new-translation-dialog';
import {useUpdateLocalization} from './update-localization';
import {Localization} from '../../i18n/localization';
import {FullPageLoader} from '../../ui/progress/full-page-loader';
import {useIsMobileMediaQuery} from '../../utils/hooks/is-mobile-media-query';
import {useVirtualizer} from '@tanstack/react-virtual';
import {useNavigate} from '../../utils/hooks/use-navigate';
import {useUploadTranslationFile} from '@common/admin/translations/use-upload-translation-file';
import {
Menu,
MenuItem,
MenuTrigger,
} from '@common/ui/navigation/menu/menu-trigger';
import {MoreVertIcon} from '@common/icons/material/MoreVert';
import {downloadFileFromUrl} from '@common/uploads/utils/download-file-from-url';
import {openUploadWindow} from '@common/uploads/utils/open-upload-window';
import {UploadInputType} from '@common/uploads/types/upload-input-config';
type Lines = Record<string, string>;
export function TranslationManagementPage() {
const {localeId} = useParams();
const {data, isLoading} = useLocaleWithLines(localeId!);
const localization = data?.localization;
if (isLoading || !localization) {
return <FullPageLoader />;
}
return <Form localization={localization} />;
}
interface FormProps {
localization: Localization;
}
function Form({localization}: FormProps) {
const [lines, setLines] = useState<Lines>(localization.lines || {});
const navigate = useNavigate();
const updateLocalization = useUpdateLocalization();
const [searchQuery, setSearchQuery] = useState<string>('');
return (
<form
className="flex h-full flex-col p-14 md:p-24"
onSubmit={e => {
e.preventDefault();
updateLocalization.mutate(
{id: localization.id, lines},
{
onSuccess: () => {
navigate('/admin/localizations');
},
},
);
}}
>
<Header
localization={localization}
setLines={setLines}
lines={lines}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
isLoading={updateLocalization.isPending}
/>
<LinesList lines={lines} setLines={setLines} searchQuery={searchQuery} />
</form>
);
}
interface HeaderProps {
localization: Localization;
lines: Lines;
setLines: (lines: Lines) => void;
searchQuery: string;
setSearchQuery: (value: string) => void;
isLoading: boolean;
}
function Header({
localization,
searchQuery,
setSearchQuery,
isLoading,
lines,
setLines,
}: HeaderProps) {
const navigate = useNavigate();
const isMobile = useIsMobileMediaQuery();
const {trans} = useTrans();
return (
<div className="flex-shrink-0">
<Breadcrumb size="lg" className="mb-16">
<BreadcrumbItem
onSelected={() => {
navigate('/admin/localizations');
}}
>
<Trans message="Localizations" />
</BreadcrumbItem>
<BreadcrumbItem>
<Trans
message=":locale translations"
values={{locale: localization.name}}
/>
</BreadcrumbItem>
</Breadcrumb>
<div className="mb-24 flex items-center gap-32 md:gap-12">
<div className="max-w-440 flex-auto">
<TextField
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
startAdornment={<SearchIcon />}
placeholder={trans({message: 'Type to search...'})}
/>
</div>
<DialogTrigger
type="modal"
onClose={newTranslation => {
if (newTranslation) {
const newLines = {...lines};
newLines[newTranslation.key] = newTranslation.value;
setLines(newLines);
}
}}
>
{!isMobile && (
<Button
className="ml-auto"
variant="outline"
color="primary"
startIcon={<AddIcon />}
>
<Trans message="Add new" />
</Button>
)}
<NewTranslationDialog />
</DialogTrigger>
<ActionsMenuTrigger locale={localization} />
<Button
variant="flat"
color="primary"
type="submit"
disabled={isLoading}
>
{isMobile ? (
<Trans message="Save" />
) : (
<Trans message="Save translations" />
)}
</Button>
</div>
</div>
);
}
interface LinesListProps {
searchQuery?: string;
lines: Lines;
setLines: (lines: Lines) => void;
}
function LinesList({searchQuery, lines, setLines}: LinesListProps) {
const filteredLines = useMemo(() => {
return Object.entries(lines).filter(([id, translation]) => {
const lowerCaseQuery = searchQuery?.toLowerCase();
return (
!lowerCaseQuery ||
id?.toLowerCase().includes(lowerCaseQuery) ||
translation?.toLowerCase().includes(lowerCaseQuery)
);
});
}, [lines, searchQuery]);
const ref = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: filteredLines.length,
getScrollElement: () => ref.current,
estimateSize: () => 123,
});
return (
<div className="flex-auto overflow-y-auto" ref={ref}>
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{rowVirtualizer.getVirtualItems().map(virtualItem => {
const [id, translation] = filteredLines[virtualItem.index];
return (
<div
key={id}
className="absolute left-0 top-0 w-full"
style={{
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="mb-10 rounded border md:mr-10">
<div className="flex items-center justify-between gap-24 border-b px-10 py-2">
<label
className="flex-auto text-xs font-semibold"
htmlFor={id}
>
{id}
</label>
<IconButton
size="xs"
className="text-muted"
onClick={() => {
const newLines = {...lines};
delete newLines[id];
setLines(newLines);
}}
>
<CloseIcon />
</IconButton>
</div>
<div>
<textarea
id={id}
name={id}
defaultValue={translation}
className="block w-full resize-none rounded bg-inherit p-10 text-sm outline-none focus-visible:ring-2"
rows={2}
onChange={e => {
const newLines = {...lines};
newLines[id] = e.target.value;
setLines(newLines);
}}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
interface ActionsMenuTriggerProps {
locale: Localization;
}
function ActionsMenuTrigger({locale}: ActionsMenuTriggerProps) {
const uploadFile = useUploadTranslationFile();
return (
<MenuTrigger>
<IconButton
variant="outline"
size="sm"
color="primary"
disabled={uploadFile.isPending}
>
<MoreVertIcon />
</IconButton>
<Menu>
<MenuItem
value="download"
onSelected={() =>
downloadFileFromUrl(`api/v1/localizations/${locale.id}/download`)
}
>
<Trans message="Download" />
</MenuItem>
<MenuItem
value="upload"
onSelected={async () => {
const files = await openUploadWindow({
types: [UploadInputType.json],
});
if (files.length == 1) {
uploadFile.mutate({localeId: locale.id, file: files[0]});
}
}}
>
<Trans message="Upload" />
</MenuItem>
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,90 @@
import {useForm} from 'react-hook-form';
import {Dialog} from '../../ui/overlays/dialog/dialog';
import {DialogHeader} from '../../ui/overlays/dialog/dialog-header';
import {Trans} from '../../i18n/trans';
import {DialogBody} from '../../ui/overlays/dialog/dialog-body';
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
import {Form} from '../../ui/forms/form';
import {Localization} from '../../i18n/localization';
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
import {useValueLists} from '../../http/value-lists';
import {FormSelect, Option} from '../../ui/forms/select/select';
import {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';
import {Button} from '../../ui/buttons/button';
import {useUpdateLocalization} from './update-localization';
import {message} from '@common/i18n/message';
import {useTrans} from '@common/i18n/use-trans';
interface UpdateLocalizationDialogProps {
localization: Localization;
}
export function UpdateLocalizationDialog({
localization,
}: UpdateLocalizationDialogProps) {
const {trans} = useTrans();
const {formId, close} = useDialogContext();
const form = useForm<Partial<Localization>>({
defaultValues: {
id: localization.id,
name: localization.name,
language: localization.language,
},
});
const {data} = useValueLists(['languages']);
const languages = data?.languages || [];
const updateLocalization = useUpdateLocalization(form);
return (
<Dialog>
<DialogHeader>
<Trans message="Update localization" />
</DialogHeader>
<DialogBody>
<Form
form={form}
id={formId}
onSubmit={values => {
updateLocalization.mutate(values, {onSuccess: close});
}}
>
<FormTextField
name="name"
label={<Trans message="Name" />}
className="mb-30"
required
/>
<FormSelect
required
name="language"
label={<Trans message="Language" />}
selectionMode="single"
showSearchField
searchPlaceholder={trans(message('Search languages'))}
>
{languages.map(language => (
<Option value={language.code} key={language.code}>
{language.name}
</Option>
))}
</FormSelect>
</Form>
</DialogBody>
<DialogFooter>
<Button onClick={close}>
<Trans message="Cancel" />
</Button>
<Button
variant="flat"
color="primary"
type="submit"
form={formId}
disabled={updateLocalization.isPending}
>
<Trans message="Save" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,38 @@
import {useMutation} from '@tanstack/react-query';
import {UseFormReturn} from 'react-hook-form';
import {toast} from '../../ui/toast/toast';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {apiClient, queryClient} from '../../http/query-client';
import {message} from '../../i18n/message';
import {DatatableDataQueryKey} from '../../datatable/requests/paginated-resources';
import {Localization} from '../../i18n/localization';
import {onFormQueryError} from '../../errors/on-form-query-error';
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
import {getLocalWithLinesQueryKey} from './use-locale-with-lines';
interface Response extends BackendResponse {
localization: Localization;
}
function UpdateLocalization({
id,
...other
}: Partial<Localization>): Promise<Response> {
return apiClient.put(`localizations/${id}`, other).then(r => r.data);
}
export function useUpdateLocalization(
form?: UseFormReturn<Partial<Localization>>,
) {
return useMutation({
mutationFn: (props: Partial<Localization>) => UpdateLocalization(props),
onSuccess: () => {
toast(message('Localization updated'));
queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('localizations'),
});
queryClient.invalidateQueries({queryKey: getLocalWithLinesQueryKey()});
},
onError: r => (form ? onFormQueryError(r, form) : showHttpErrorToast(r)),
});
}

View File

@@ -0,0 +1,32 @@
import {useQuery} from '@tanstack/react-query';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {Localization} from '../../i18n/localization';
import {apiClient} from '../../http/query-client';
export interface FetchLocaleWithLinesResponse extends BackendResponse {
localization: Localization;
}
export const getLocalWithLinesQueryKey = (localeId?: number | string) => {
const key: (string | number)[] = ['getLocaleWithLines'];
if (localeId != null) {
key.push(localeId);
}
return key;
};
export function useLocaleWithLines(localeId: number | string) {
return useQuery({
queryKey: getLocalWithLinesQueryKey(localeId),
queryFn: () => fetchLocaleWithLines(localeId),
staleTime: Infinity,
});
}
function fetchLocaleWithLines(
localeId: number | string,
): Promise<FetchLocaleWithLinesResponse> {
return apiClient
.get(`localizations/${localeId}`)
.then(response => response.data);
}

View File

@@ -0,0 +1,43 @@
import {useMutation} from '@tanstack/react-query';
import {toast} from '../../ui/toast/toast';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {apiClient, queryClient} from '../../http/query-client';
import {message} from '../../i18n/message';
import {DatatableDataQueryKey} from '../../datatable/requests/paginated-resources';
import {Localization} from '../../i18n/localization';
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
import {getLocalWithLinesQueryKey} from './use-locale-with-lines';
import {UploadedFile} from '@common/uploads/uploaded-file';
interface Response extends BackendResponse {
localization: Localization;
}
interface Payload {
file: UploadedFile;
localeId: string | number;
}
export function useUploadTranslationFile() {
return useMutation({
mutationFn: (payload: Payload) => uploadFile(payload),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('localizations'),
});
await queryClient.invalidateQueries({
queryKey: getLocalWithLinesQueryKey(),
});
toast(message('Translation file uploaded'));
},
onError: r => showHttpErrorToast(r),
});
}
function uploadFile({localeId, file}: Payload): Promise<Response> {
const data = new FormData();
data.append('file', file.native);
return apiClient
.post(`localizations/${localeId}/upload`, data)
.then(r => r.data);
}