2
common/resources/client/admin/translations/around-the-world.svg
Executable file
2
common/resources/client/admin/translations/around-the-world.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
86
common/resources/client/admin/translations/create-localization-dialog.tsx
Executable file
86
common/resources/client/admin/translations/create-localization-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
40
common/resources/client/admin/translations/create-localization.ts
Executable file
40
common/resources/client/admin/translations/create-localization.ts
Executable 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),
|
||||
});
|
||||
}
|
||||
166
common/resources/client/admin/translations/localization-index.tsx
Executable file
166
common/resources/client/admin/translations/localization-index.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
72
common/resources/client/admin/translations/new-translation-dialog.tsx
Executable file
72
common/resources/client/admin/translations/new-translation-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
298
common/resources/client/admin/translations/translation-management-page.tsx
Executable file
298
common/resources/client/admin/translations/translation-management-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
90
common/resources/client/admin/translations/update-localization-dialog.tsx
Executable file
90
common/resources/client/admin/translations/update-localization-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
38
common/resources/client/admin/translations/update-localization.ts
Executable file
38
common/resources/client/admin/translations/update-localization.ts
Executable 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)),
|
||||
});
|
||||
}
|
||||
32
common/resources/client/admin/translations/use-locale-with-lines.ts
Executable file
32
common/resources/client/admin/translations/use-locale-with-lines.ts
Executable 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);
|
||||
}
|
||||
43
common/resources/client/admin/translations/use-upload-translation-file.ts
Executable file
43
common/resources/client/admin/translations/use-upload-translation-file.ts
Executable 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);
|
||||
}
|
||||
Reference in New Issue
Block a user