mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 14:39:06 +00:00
feat: add user defined translations (#1756)
* feat: add user defined translations * add refetch key for translation (#1757) --------- Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
|
|
||||||
|
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
|
||||||
|
|
||||||
const assetsPath = `${apiEntryUrl}/assets`;
|
const assetsPath = `${apiEntryUrl}/assets`;
|
||||||
|
|
||||||
@@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise<string> {
|
|||||||
const res = await axios.post(`${assetsPath}/css/restore`);
|
const res = await axios.post(`${assetsPath}/css/restore`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to get user translation
|
||||||
|
*/
|
||||||
|
export async function getUserTranslation(): Promise<TranslationObject> {
|
||||||
|
const res = await axios.get(customTranslationsURL);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to post user translation
|
||||||
|
*/
|
||||||
|
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
|
||||||
|
await axios.post(`${assetsPath}/translations`, {
|
||||||
|
translation,
|
||||||
|
});
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ export const URL_PRESETS = ['urlpresets'];
|
|||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const CLIENT_LIST = ['clientList'];
|
export const CLIENT_LIST = ['clientList'];
|
||||||
export const REPORT = ['report'];
|
export const REPORT = ['report'];
|
||||||
|
export const TRANSLATION = ['translation'];
|
||||||
|
|
||||||
// API URLs
|
// API URLs
|
||||||
export const apiEntryUrl = `${serverURL}/data`;
|
export const apiEntryUrl = `${serverURL}/data`;
|
||||||
|
|
||||||
const userAssetsPath = 'user';
|
const userAssetsPath = 'user';
|
||||||
const cssOverridePath = 'styles/override.css';
|
const cssOverridePath = 'styles/override.css';
|
||||||
|
const customTranslationsPath = 'translations/translations.json';
|
||||||
|
|
||||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||||
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||||
|
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { langEn } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getUserTranslation } from '../../common/api/assets';
|
||||||
|
import { TRANSLATION } from '../../common/api/constants';
|
||||||
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
|
|
||||||
|
export function useCustomTranslation() {
|
||||||
|
const { data, status, refetch } = useQuery({
|
||||||
|
queryKey: TRANSLATION,
|
||||||
|
queryFn: getUserTranslation,
|
||||||
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
});
|
||||||
|
return { data: data ?? langEn, status, refetch };
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
REPORT,
|
REPORT,
|
||||||
RUNDOWN,
|
RUNDOWN,
|
||||||
RUNTIME,
|
RUNTIME,
|
||||||
|
TRANSLATION,
|
||||||
URL_PRESETS,
|
URL_PRESETS,
|
||||||
VIEW_SETTINGS,
|
VIEW_SETTINGS,
|
||||||
} from '../api/constants';
|
} from '../api/constants';
|
||||||
@@ -173,6 +174,9 @@ export const connectSocket = () => {
|
|||||||
case RefetchKey.ViewSettings:
|
case RefetchKey.ViewSettings:
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
|
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
|
||||||
break;
|
break;
|
||||||
|
case RefetchKey.Translation:
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||||
|
break;
|
||||||
default: {
|
default: {
|
||||||
target satisfies never;
|
target satisfies never;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { lazy, useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { Settings } from 'ontime-types';
|
import { Settings } from 'ontime-types';
|
||||||
|
|
||||||
import { postSettings } from '../../../../common/api/settings';
|
import { postSettings } from '../../../../common/api/settings';
|
||||||
@@ -15,6 +16,8 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
|||||||
|
|
||||||
import GeneralPinInput from './composite/GeneralPinInput';
|
import GeneralPinInput from './composite/GeneralPinInput';
|
||||||
|
|
||||||
|
const TranslationModal = lazy(() => import('./composite/CustomTranslationModal'));
|
||||||
|
|
||||||
export default function GeneralSettings() {
|
export default function GeneralSettings() {
|
||||||
const { data, status, refetch } = useSettings();
|
const { data, status, refetch } = useSettings();
|
||||||
const {
|
const {
|
||||||
@@ -34,6 +37,8 @@ export default function GeneralSettings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [isOpen, handler] = useDisclosure();
|
||||||
|
|
||||||
// update form if we get new data from server
|
// update form if we get new data from server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -63,112 +68,124 @@ export default function GeneralSettings() {
|
|||||||
const isLoading = status === 'pending';
|
const isLoading = status === 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section
|
<>
|
||||||
as='form'
|
<TranslationModal isOpen={isOpen} onClose={handler.close} />
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
<Panel.Section
|
||||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
as='form'
|
||||||
id='app-settings'
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
>
|
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||||
<Panel.Card>
|
id='app-settings'
|
||||||
<Panel.SubHeader>
|
>
|
||||||
General settings
|
<Panel.Card>
|
||||||
<Panel.InlineElements>
|
<Panel.SubHeader>
|
||||||
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
General settings
|
||||||
Revert to saved
|
<Panel.InlineElements>
|
||||||
</Button>
|
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
||||||
<Button type='submit' form='app-settings' loading={isSubmitting} disabled={disableSubmit} variant='primary'>
|
Revert to saved
|
||||||
Save
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
</Panel.InlineElements>
|
type='submit'
|
||||||
</Panel.SubHeader>
|
form='app-settings'
|
||||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
name='general-settings-submit'
|
||||||
<Panel.Divider />
|
loading={isSubmitting}
|
||||||
<Panel.Section>
|
disabled={disableSubmit}
|
||||||
<Panel.Loader isLoading={isLoading} />
|
variant='primary'
|
||||||
<Panel.ListGroup>
|
>
|
||||||
<Panel.ListItem>
|
Save
|
||||||
<Panel.Field
|
</Button>
|
||||||
title='Ontime server port'
|
</Panel.InlineElements>
|
||||||
description={
|
</Panel.SubHeader>
|
||||||
isOntimeCloud
|
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||||
? 'Server port disabled for Ontime Cloud'
|
<Panel.Divider />
|
||||||
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
<Panel.Section>
|
||||||
}
|
<Panel.Loader isLoading={isLoading} />
|
||||||
error={errors.serverPort?.message}
|
<Panel.ListGroup>
|
||||||
/>
|
<Panel.ListItem>
|
||||||
<Input
|
<Panel.Field
|
||||||
id='serverPort'
|
title='Ontime server port'
|
||||||
type='number'
|
description={
|
||||||
maxLength={5}
|
isOntimeCloud
|
||||||
style={{ width: '75px' }}
|
? 'Server port disabled for Ontime Cloud'
|
||||||
disabled={isOntimeCloud}
|
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
||||||
{...register('serverPort', {
|
}
|
||||||
required: { value: true, message: 'Required field' },
|
error={errors.serverPort?.message}
|
||||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
/>
|
||||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
<Input
|
||||||
pattern: {
|
id='serverPort'
|
||||||
value: isOnlyNumbers,
|
type='number'
|
||||||
message: 'Value should be numeric',
|
maxLength={5}
|
||||||
},
|
style={{ width: '75px' }}
|
||||||
})}
|
disabled={isOntimeCloud}
|
||||||
/>
|
{...register('serverPort', {
|
||||||
</Panel.ListItem>
|
required: { value: true, message: 'Required field' },
|
||||||
<Panel.ListItem>
|
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||||
<Panel.Field
|
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||||
title='Editor pin code'
|
pattern: {
|
||||||
description='Protect the editor view with a pin code'
|
value: isOnlyNumbers,
|
||||||
error={errors.editorKey?.message}
|
message: 'Value should be numeric',
|
||||||
/>
|
},
|
||||||
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
|
})}
|
||||||
</Panel.ListItem>
|
/>
|
||||||
<Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.ListItem>
|
||||||
title='Operator pin code'
|
<Panel.Field
|
||||||
description='Protect the operator and cuesheet views with a pin code'
|
title='Editor pin code'
|
||||||
error={errors.operatorKey?.message}
|
description='Protect the editor view with a pin code'
|
||||||
/>
|
error={errors.editorKey?.message}
|
||||||
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
|
/>
|
||||||
</Panel.ListItem>
|
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
|
||||||
<Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.ListItem>
|
||||||
title='Time format'
|
<Panel.Field
|
||||||
description='Default time format to show in views 12 /24 hours'
|
title='Operator pin code'
|
||||||
error={errors.timeFormat?.message}
|
description='Protect the operator and cuesheet views with a pin code'
|
||||||
/>
|
error={errors.operatorKey?.message}
|
||||||
<Select
|
/>
|
||||||
value={watch('timeFormat')}
|
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
|
||||||
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
|
</Panel.ListItem>
|
||||||
defaultValue='24'
|
<Panel.ListItem>
|
||||||
options={[
|
<Panel.Field
|
||||||
{ value: '12', label: '12 hours 11:00:10 PM' },
|
title='Time format'
|
||||||
{ value: '24', label: '24 hours 23:00:10' },
|
description='Default time format to show in views 12 /24 hours'
|
||||||
]}
|
error={errors.timeFormat?.message}
|
||||||
/>
|
/>
|
||||||
</Panel.ListItem>
|
<Select
|
||||||
<Panel.ListItem>
|
value={watch('timeFormat')}
|
||||||
<Panel.Field
|
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
|
||||||
title='Views language'
|
defaultValue='24'
|
||||||
description='Language to be displayed in views'
|
options={[
|
||||||
error={errors.language?.message}
|
{ value: '12', label: '12 hours 11:00:10 PM' },
|
||||||
/>
|
{ value: '24', label: '24 hours 23:00:10' },
|
||||||
<Select
|
]}
|
||||||
value={watch('language')}
|
/>
|
||||||
onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
|
</Panel.ListItem>
|
||||||
disabled={disableInputs}
|
<Panel.ListItem>
|
||||||
defaultValue='en'
|
<Panel.Field
|
||||||
options={[
|
title='Views language'
|
||||||
{ value: 'en', label: 'English' },
|
description='Language to be displayed in views'
|
||||||
{ value: 'fr', label: 'French' },
|
error={errors.language?.message}
|
||||||
{ value: 'de', label: 'German' },
|
/>
|
||||||
{ value: 'it', label: 'Italian' },
|
<Select
|
||||||
{ value: 'pt', label: 'Portuguese' },
|
value={watch('language')}
|
||||||
{ value: 'es', label: 'Spanish' },
|
onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
|
||||||
]}
|
disabled={disableInputs}
|
||||||
/>
|
defaultValue='en'
|
||||||
</Panel.ListItem>
|
options={[
|
||||||
</Panel.ListGroup>
|
{ value: 'en', label: 'English' },
|
||||||
</Panel.Section>
|
{ value: 'fr', label: 'French' },
|
||||||
</Panel.Card>
|
{ value: 'de', label: 'German' },
|
||||||
</Panel.Section>
|
{ value: 'it', label: 'Italian' },
|
||||||
|
{ value: 'pt', label: 'Portuguese' },
|
||||||
|
{ value: 'es', label: 'Spanish' },
|
||||||
|
{ value: 'custom', label: 'Custom' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button onClick={handler.open}>Edit custom translation</Button>
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Section>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { langEn, TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
|
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||||
|
import Button from '../../../../../common/components/buttons/Button';
|
||||||
|
import Info from '../../../../../common/components/info/Info';
|
||||||
|
import Input from '../../../../../common/components/input/input/Input';
|
||||||
|
import Modal from '../../../../../common/components/modal/Modal';
|
||||||
|
import { useTranslation } from '../../../../../translation/TranslationProvider';
|
||||||
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
interface CustomTranslationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
|
||||||
|
const { userTranslation, postUserTranslation } = useTranslation();
|
||||||
|
|
||||||
|
const defaultValues = useMemo(() => {
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
Object.keys(langEn).forEach((key) => {
|
||||||
|
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}, [userTranslation]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting, isDirty, errors, isValid },
|
||||||
|
setError,
|
||||||
|
} = useForm({
|
||||||
|
defaultValues,
|
||||||
|
resetOptions: {
|
||||||
|
keepDirtyValues: true,
|
||||||
|
},
|
||||||
|
mode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const translationData: Record<string, string> = {};
|
||||||
|
Object.keys(formData).forEach((key) => {
|
||||||
|
translationData[toApiKey(key)] = formData[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
await postUserTranslation(translationData as TranslationObject);
|
||||||
|
reset(formData);
|
||||||
|
} catch (error) {
|
||||||
|
setError('root', { message: maybeAxiosError(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title='Edit custom translations'
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
showCloseButton
|
||||||
|
showBackdrop
|
||||||
|
bodyElements={
|
||||||
|
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='custom-translations-form'>
|
||||||
|
<Info>
|
||||||
|
Provide custom translations for the public views of Ontime. <br />
|
||||||
|
You will need to activate this in the settings by selecting "Custom" as the views language.
|
||||||
|
</Info>
|
||||||
|
<Panel.ListGroup>
|
||||||
|
{Object.entries(langEn).map(([key, value]) => (
|
||||||
|
<Panel.ListItem key={key}>
|
||||||
|
<Panel.Field title={value} description='' error={errors[toFormKey(key)]?.message} />
|
||||||
|
<Input
|
||||||
|
maxLength={150}
|
||||||
|
{...register(toFormKey(key), {
|
||||||
|
required: 'This field is required',
|
||||||
|
})}
|
||||||
|
placeholder={value}
|
||||||
|
/>
|
||||||
|
</Panel.ListItem>
|
||||||
|
))}
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Section>
|
||||||
|
}
|
||||||
|
footerElements={
|
||||||
|
<div>
|
||||||
|
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
|
<Panel.InlineElements align='apart'>
|
||||||
|
<Panel.InlineElements>
|
||||||
|
<Button size='large' onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
size='large'
|
||||||
|
type='submit'
|
||||||
|
form='custom-translations-form'
|
||||||
|
disabled={isSubmitting || !isDirty || !isValid}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormKey(key: string) {
|
||||||
|
return key.replace('.', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiKey(key: string) {
|
||||||
|
return key.replace('_', '.');
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createContext, PropsWithChildren, useCallback, useContext } from 'react';
|
import { createContext, PropsWithChildren, useCallback, useContext } from 'react';
|
||||||
|
import { langEn, TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
|
import { postUserTranslation } from '../common/api/assets';
|
||||||
|
import { useCustomTranslation } from '../common/hooks-query/useCustomTranslation';
|
||||||
import useSettings from '../common/hooks-query/useSettings';
|
import useSettings from '../common/hooks-query/useSettings';
|
||||||
|
|
||||||
import { langDe } from './languages/de';
|
import { langDe } from './languages/de';
|
||||||
import { langEn } from './languages/en';
|
|
||||||
import { langEs } from './languages/es';
|
import { langEs } from './languages/es';
|
||||||
import { langFr } from './languages/fr';
|
import { langFr } from './languages/fr';
|
||||||
import { langIt } from './languages/it';
|
import { langIt } from './languages/it';
|
||||||
@@ -21,15 +23,20 @@ const translationsList = {
|
|||||||
export type TranslationKey = keyof typeof langEn;
|
export type TranslationKey = keyof typeof langEn;
|
||||||
|
|
||||||
interface TranslationContextValue {
|
interface TranslationContextValue {
|
||||||
|
userTranslation: TranslationObject;
|
||||||
getLocalizedString: (key: TranslationKey, lang?: string) => string;
|
getLocalizedString: (key: TranslationKey, lang?: string) => string;
|
||||||
|
postUserTranslation: (translation: TranslationObject) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TranslationContext = createContext<TranslationContextValue>({
|
const TranslationContext = createContext<TranslationContextValue>({
|
||||||
|
userTranslation: langEn,
|
||||||
getLocalizedString: () => '',
|
getLocalizedString: () => '',
|
||||||
|
postUserTranslation: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
||||||
const { data } = useSettings();
|
const { data } = useSettings();
|
||||||
|
const { data: translationData } = useCustomTranslation();
|
||||||
|
|
||||||
const getLocalizedString = useCallback(
|
const getLocalizedString = useCallback(
|
||||||
(key: TranslationKey, lang = data?.language || 'en'): string => {
|
(key: TranslationKey, lang = data?.language || 'en'): string => {
|
||||||
@@ -37,20 +44,24 @@ export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
|||||||
if (key in translationsList[lang as keyof typeof translationsList]) {
|
if (key in translationsList[lang as keyof typeof translationsList]) {
|
||||||
return translationsList[lang as keyof typeof translationsList][key];
|
return translationsList[lang as keyof typeof translationsList][key];
|
||||||
}
|
}
|
||||||
|
} else if (lang === 'custom') {
|
||||||
|
return translationData[key];
|
||||||
}
|
}
|
||||||
return langEn[key];
|
return langEn[key];
|
||||||
},
|
},
|
||||||
[data?.language],
|
[data?.language, translationData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = {
|
||||||
|
userTranslation: translationData,
|
||||||
getLocalizedString,
|
getLocalizedString,
|
||||||
|
postUserTranslation,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>;
|
return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTranslation = () => {
|
export const useTranslation = () => {
|
||||||
const { getLocalizedString } = useContext(TranslationContext);
|
const { userTranslation, getLocalizedString, postUserTranslation } = useContext(TranslationContext);
|
||||||
return { getLocalizedString };
|
return { userTranslation, getLocalizedString, postUserTranslation };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
export const langDe: TranslationObject = {
|
export const langDe: TranslationObject = {
|
||||||
'common.expected_finish': 'Erwartetes Ende',
|
'common.expected_finish': 'Erwartetes Ende',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
export const langEs: TranslationObject = {
|
export const langEs: TranslationObject = {
|
||||||
'common.expected_finish': 'Finalización esperada',
|
'common.expected_finish': 'Finalización esperada',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
export const langFr: TranslationObject = {
|
export const langFr: TranslationObject = {
|
||||||
'common.expected_finish': 'Fin estimée à',
|
'common.expected_finish': 'Fin estimée à',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
export const langIt: TranslationObject = {
|
export const langIt: TranslationObject = {
|
||||||
'common.expected_finish': 'Fine Prevista',
|
'common.expected_finish': 'Fine Prevista',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
export const langPt: TranslationObject = {
|
export const langPt: TranslationObject = {
|
||||||
'common.expected_finish': 'Término esperado',
|
'common.expected_finish': 'Término esperado',
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
|
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
|
||||||
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
|
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
|
||||||
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
|
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
|
||||||
"prebuild": "tsx ./scripts/bundleCss.ts",
|
"prebuild": "tsx ./scripts/bundleCss.ts && tsx ./scripts/bundleTranslation.ts",
|
||||||
"build": "node esbuild.electron.js",
|
"build": "node esbuild.electron.js",
|
||||||
"build:electron": "node esbuild.electron.js",
|
"build:electron": "node esbuild.electron.js",
|
||||||
"build:local": "node esbuild.dev.js",
|
"build:local": "node esbuild.dev.js",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { defaultTranslation } from '../src/user/translations/bundledTranslation';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to write contents of default translation to translation.json
|
||||||
|
*/
|
||||||
|
async function bundleTranslation() {
|
||||||
|
try {
|
||||||
|
const translationDir = path.resolve(process.cwd(), 'src', 'user', 'translations');
|
||||||
|
const translationsFile = path.resolve(translationDir, 'translations.json');
|
||||||
|
|
||||||
|
if (!existsSync(translationsFile)) {
|
||||||
|
throw new Error('File does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(translationsFile, defaultTranslation, { encoding: 'utf8' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed writing to translations file: ', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleTranslation();
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { ErrorResponse } from 'ontime-types';
|
import { RefetchKey, type ErrorResponse } from 'ontime-types';
|
||||||
import { validatePostCss } from './assets.validation.js';
|
import { validatePostCss, validatePostTranslation } from './assets.validation.js';
|
||||||
import { readCssFile, writeCssFile } from './assets.service.js';
|
import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||||
|
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -38,3 +39,21 @@ router.post('/css/restore', async (_req: Request, res: Response<string | ErrorRe
|
|||||||
res.status(500).send({ message });
|
res.status(500).send({ message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/translations', validatePostTranslation, async (req: Request, res: Response<never | ErrorResponse>) => {
|
||||||
|
const { translation } = req.body;
|
||||||
|
|
||||||
|
if (!translation) {
|
||||||
|
res.status(400).send({ message: 'translation payload is required ' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeUserTranslation(translation);
|
||||||
|
sendRefetch(RefetchKey.Translation);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(500).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { publicFiles } from '../../setup/index.js';
|
import type { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { publicFiles } from '../../setup/index.js';
|
||||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,3 +34,13 @@ export async function writeCssFile(css: string) {
|
|||||||
|
|
||||||
await writeFile(path, css, { encoding: 'utf8' });
|
await writeFile(path, css, { encoding: 'utf8' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the user's custom translation file
|
||||||
|
* @param translations the updated translations to write to file
|
||||||
|
*/
|
||||||
|
export async function writeUserTranslation(translations: TranslationObject) {
|
||||||
|
const path = publicFiles.translationsFile;
|
||||||
|
const translationsString = JSON.stringify(translations, null, 2);
|
||||||
|
await writeFile(path, translationsString, { encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { body } from 'express-validator';
|
import { body } from 'express-validator';
|
||||||
|
|
||||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||||
|
|
||||||
export const validatePostCss = [body('css').isString().trim(), requestValidationFunction];
|
export const validatePostCss = [body('css').isString().trim(), requestValidationFunction];
|
||||||
|
|
||||||
|
export const validatePostTranslation = [
|
||||||
|
body('translation')
|
||||||
|
.custom((v) => v != null && typeof v === 'object' && !Array.isArray(v))
|
||||||
|
.withMessage('translation must be an object (key -> string)')
|
||||||
|
.bail(),
|
||||||
|
body('translation.*').isString().trim().notEmpty(),
|
||||||
|
requestValidationFunction,
|
||||||
|
];
|
||||||
|
|||||||
@@ -29,12 +29,13 @@ import { getDataProvider } from './classes/data-provider/DataProvider.js';
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
import { logger } from './classes/Logger.js';
|
import { logger } from './classes/Logger.js';
|
||||||
|
import { populateDemo } from './setup/loadDemo.js';
|
||||||
|
import { populateTranslation } from './setup/loadTranslations.js';
|
||||||
import { populateStyles } from './setup/loadStyles.js';
|
import { populateStyles } from './setup/loadStyles.js';
|
||||||
import { eventStore } from './stores/EventStore.js';
|
import { eventStore } from './stores/EventStore.js';
|
||||||
import { runtimeService } from './services/runtime-service/runtime.service.js';
|
import { runtimeService } from './services/runtime-service/runtime.service.js';
|
||||||
import { RestorePoint, restoreService } from './services/RestoreService.js';
|
import { RestorePoint, restoreService } from './services/RestoreService.js';
|
||||||
import * as messageService from './services/message-service/message.service.js';
|
import * as messageService from './services/message-service/message.service.js';
|
||||||
import { populateDemo } from './setup/loadDemo.js';
|
|
||||||
import { getState } from './stores/runtimeState.js';
|
import { getState } from './stores/runtimeState.js';
|
||||||
import { initialiseProject } from './services/project-service/ProjectService.js';
|
import { initialiseProject } from './services/project-service/ProjectService.js';
|
||||||
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
|
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
|
||||||
@@ -157,6 +158,7 @@ export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable
|
|||||||
|
|
||||||
await clearUploadfolder();
|
await clearUploadfolder();
|
||||||
populateStyles();
|
populateStyles();
|
||||||
|
populateTranslation();
|
||||||
await populateDemo();
|
await populateDemo();
|
||||||
const project = await initialiseProject();
|
const project = await initialiseProject();
|
||||||
logger.info(LogOrigin.Server, `Initialised Ontime with ${project}`);
|
logger.info(LogOrigin.Server, `Initialised Ontime with ${project}`);
|
||||||
|
|||||||
@@ -33,4 +33,8 @@ export const config = {
|
|||||||
},
|
},
|
||||||
uploads: 'uploads',
|
uploads: 'uploads',
|
||||||
logo: 'logo',
|
logo: 'logo',
|
||||||
|
translations: {
|
||||||
|
directory: 'translations',
|
||||||
|
filename: 'translations.json',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,12 +81,16 @@ export const srcFiles = {
|
|||||||
clientIndexHtml: join(srcDir.clientDir, 'index.html'),
|
clientIndexHtml: join(srcDir.clientDir, 'index.html'),
|
||||||
/** Path to bundled CSS */
|
/** Path to bundled CSS */
|
||||||
cssOverride: join(srcDir.root, config.user, config.styles.directory, config.styles.filename),
|
cssOverride: join(srcDir.root, config.user, config.styles.directory, config.styles.filename),
|
||||||
|
/** Path to bundled translation */
|
||||||
|
translationsFile: join(srcDir.root, config.user, config.translations.directory, config.translations.filename),
|
||||||
/** Path to bundled external readme */
|
/** Path to bundled external readme */
|
||||||
externalReadme: join(srcDir.root, config.external, 'README.md'),
|
externalReadme: join(srcDir.root, config.external, 'README.md'),
|
||||||
/** Path to bundled user readme */
|
/** Path to bundled user readme */
|
||||||
userReadme: join(srcDir.root, config.user, 'README.md'),
|
userReadme: join(srcDir.root, config.user, 'README.md'),
|
||||||
/** Path to bundled CSS readme */
|
/** Path to bundled CSS readme */
|
||||||
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
|
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
|
||||||
|
/** Path to bundled translation readme */
|
||||||
|
translationReadme: join(srcDir.root, config.user, config.translations.directory, 'README.md'),
|
||||||
/** Path to login */
|
/** Path to login */
|
||||||
login: join(srcDir.root, 'html/login.html'),
|
login: join(srcDir.root, 'html/login.html'),
|
||||||
};
|
};
|
||||||
@@ -136,6 +140,8 @@ export const publicDir = {
|
|||||||
/** path to external styles override */
|
/** path to external styles override */
|
||||||
stylesDir: join(resolvePublicDirectory, config.user, config.styles.directory),
|
stylesDir: join(resolvePublicDirectory, config.user, config.styles.directory),
|
||||||
logoDir: join(resolvePublicDirectory, config.user, config.logo),
|
logoDir: join(resolvePublicDirectory, config.user, config.logo),
|
||||||
|
/** path to translations folder */
|
||||||
|
translationsDir: join(resolvePublicDirectory, config.user, config.translations.directory),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,10 +154,14 @@ export const publicFiles = {
|
|||||||
restoreFile: join(publicDir.root, config.restoreFile),
|
restoreFile: join(publicDir.root, config.restoreFile),
|
||||||
/** path to CSS override file */
|
/** path to CSS override file */
|
||||||
cssOverride: join(publicDir.stylesDir, config.styles.filename),
|
cssOverride: join(publicDir.stylesDir, config.styles.filename),
|
||||||
|
/** path to custom translation file */
|
||||||
|
translationsFile: join(publicDir.translationsDir, config.translations.filename),
|
||||||
/** path to external readme file */
|
/** path to external readme file */
|
||||||
externalReadme: join(publicDir.externalDir, 'README.md'),
|
externalReadme: join(publicDir.externalDir, 'README.md'),
|
||||||
/** path to user readme file */
|
/** path to user readme file */
|
||||||
userReadme: join(publicDir.userDir, 'README.md'),
|
userReadme: join(publicDir.userDir, 'README.md'),
|
||||||
/** path to CSS readme file */
|
/** path to CSS readme file */
|
||||||
cssReadme: join(publicDir.stylesDir, 'README.md'),
|
cssReadme: join(publicDir.stylesDir, 'README.md'),
|
||||||
|
/** path to translation readme file */
|
||||||
|
translationReadme: join(publicDir.translationsDir, 'README.md'),
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { copyFileSync, existsSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
import { ensureDirectory } from '../utils/fileManagement.js';
|
||||||
|
import { defaultTranslation } from '../user/translations/bundledTranslations.js';
|
||||||
|
|
||||||
|
import { publicDir, publicFiles, srcFiles } from './index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensures directories exist and populates translation
|
||||||
|
*/
|
||||||
|
export const populateTranslation = () => {
|
||||||
|
ensureDirectory(publicDir.translationsDir);
|
||||||
|
// if translations doesn't exist we want to use startup translation
|
||||||
|
try {
|
||||||
|
copyFileSync(srcFiles.translationReadme, publicFiles.translationReadme);
|
||||||
|
if (!existsSync(publicFiles.translationsFile)) {
|
||||||
|
// copy the startup translation only if user doesnt have one
|
||||||
|
writeFileSync(publicFiles.translationsFile, defaultTranslation, { encoding: 'utf-8' });
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* we do not handle this */
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# translations
|
||||||
|
|
||||||
|
The translations folder contain the user's custom translations which is used for custom translations in Ontime.
|
||||||
|
The file should be named `translations.json`.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { langEn } from 'ontime-types';
|
||||||
|
|
||||||
|
export const defaultTranslation = JSON.stringify(langEn, null, 2);
|
||||||
@@ -6,4 +6,5 @@ export enum RefetchKey {
|
|||||||
Rundown = 'rundown',
|
Rundown = 'rundown',
|
||||||
UrlPresets = 'url-presets',
|
UrlPresets = 'url-presets',
|
||||||
ViewSettings = 'view-settings',
|
ViewSettings = 'view-settings',
|
||||||
|
Translation = 'translation',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,7 @@ export {
|
|||||||
type TimeField,
|
type TimeField,
|
||||||
SupportedEntry as SupportedEntry,
|
SupportedEntry as SupportedEntry,
|
||||||
} from './definitions/core/OntimeEntry.js';
|
} from './definitions/core/OntimeEntry.js';
|
||||||
export type {
|
export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js';
|
||||||
RundownEntries,
|
|
||||||
Rundown,
|
|
||||||
ProjectRundowns,
|
|
||||||
} from './definitions/core/Rundown.type.js';
|
|
||||||
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
|
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
|
||||||
export { TimerType } from './definitions/TimerType.type.js';
|
export { TimerType } from './definitions/TimerType.type.js';
|
||||||
|
|
||||||
@@ -131,3 +127,6 @@ export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
|
|||||||
|
|
||||||
// Colour
|
// Colour
|
||||||
export type { RGBColour } from './definitions/Colour.type.js';
|
export type { RGBColour } from './definitions/Colour.type.js';
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
export { langEn, type TranslationObject } from './translations/index.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user