Add localization support to admin status and error messages (#4631)

* Initial plan

* Add localization support to admin form status and error messages

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Format updated files with prettier

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Replace t() with Translation component in admin page JSX

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* update package-lock.json

* Update web/i18n/en/translation.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: gabek <414923+gabek@users.noreply.github.com>
Co-authored-by: Gabe Kangas <gabek@real-ity.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2025-10-13 16:29:40 -07:00
committed by GitHub
co-authored by gabek Copilot copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Gabe Kangas
parent fd89c6e8f2
commit 383b80851b
18 changed files with 702 additions and 297 deletions
+6 -1
View File
@@ -1,5 +1,6 @@
import { Popconfirm, Select, Typography } from 'antd';
import React, { FC, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'next-export-i18n';
import { AlertMessageContext } from '../../utils/alert-message-context';
import {
API_VIDEO_CODEC,
@@ -13,11 +14,13 @@ import {
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { ServerStatusContext } from '../../utils/server-status-context';
import { Localization } from '../../types/localization';
import { FormStatusIndicator } from './FormStatusIndicator';
export type CodecSelectorProps = {};
export const CodecSelector: FC<CodecSelectorProps> = () => {
const { t } = useTranslation();
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { videoCodec, supportedCodecs } = serverConfig || {};
@@ -60,7 +63,9 @@ export const CodecSelector: FC<CodecSelectorProps> = () => {
value: pendingSaveCodec,
path: 'videoSettings',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Video codec updated.'));
setSubmitStatus(
createInputStatus(STATUS_SUCCESS, t(Localization.Admin.StatusMessages.videoCodecUpdated)),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
if (serverStatusData.online) {
@@ -4,6 +4,7 @@ import React, { FC, useContext, useState } from 'react';
import { Typography, Table, Modal, Button, Alert } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import { ServerStatusContext } from '../../utils/server-status-context';
import { AlertMessageContext } from '../../utils/alert-message-context';
import { UpdateArgs, VideoVariant } from '../../types/config-section';
@@ -24,6 +25,7 @@ import {
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { Localization } from '../../types/localization';
import { FormStatusIndicator } from './FormStatusIndicator';
const { Title } = Typography;
@@ -36,6 +38,7 @@ const DeleteOutlined = dynamic(() => import('@ant-design/icons/DeleteOutlined'),
// eslint-disable-next-line import/prefer-default-export
export const CurrentVariantsTable: FC = () => {
const { t } = useTranslation();
const [displayModal, setDisplayModal] = useState(false);
const [modalProcessing, setModalProcessing] = useState(false);
const [editId, setEditId] = useState(0);
@@ -87,7 +90,9 @@ export const CurrentVariantsTable: FC = () => {
setModalProcessing(false);
handleModalCancel();
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Variants updated'));
setSubmitStatus(
createInputStatus(STATUS_SUCCESS, t(Localization.Admin.StatusMessages.variantsUpdated)),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
if (serverStatusData.online) {
+25 -5
View File
@@ -2,6 +2,7 @@ import { Button, Upload } from 'antd';
import { RcFile } from 'antd/lib/upload/interface';
import React, { useState, useContext, FC } from 'react';
import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import { FormStatusIndicator } from './FormStatusIndicator';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
@@ -17,6 +18,7 @@ import {
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { NEXT_PUBLIC_API_HOST } from '../../utils/apis';
import { Localization } from '../../types/localization';
import {
ACCEPTED_IMAGE_TYPES,
@@ -37,6 +39,7 @@ const UploadOutlined = dynamic(() => import('@ant-design/icons/UploadOutlined'),
// eslint-disable-next-line import/prefer-default-export
export const EditLogo: FC = () => {
const { t } = useTranslation();
const [logoUrl, setlogoUrl] = useState(null);
const [loading, setLoading] = useState(false);
const [logoCachedbuster, setLogoCacheBuster] = useState(0);
@@ -64,16 +67,28 @@ export const EditLogo: FC = () => {
// eslint-disable-next-line consistent-return
return new Promise<void>((res, rej) => {
if (file.size > MAX_IMAGE_FILESIZE) {
const msg = `File size is too big: ${readableBytes(file.size)}`;
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`));
const msg = t(Localization.Admin.StatusMessages.fileSizeTooBig, {
size: readableBytes(file.size),
});
setSubmitStatus(
createInputStatus(
STATUS_ERROR,
t(Localization.Admin.StatusMessages.thereWasAnError, { message: msg }),
),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
setLoading(false);
// eslint-disable-next-line no-promise-executor-return
return rej();
}
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
const msg = `File type is not supported: ${file.type}`;
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`));
const msg = t(Localization.Admin.StatusMessages.fileTypeNotSupported, { type: file.type });
setSubmitStatus(
createInputStatus(
STATUS_ERROR,
t(Localization.Admin.StatusMessages.thereWasAnError, { message: msg }),
),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
setLoading(false);
// eslint-disable-next-line no-promise-executor-return
@@ -101,7 +116,12 @@ export const EditLogo: FC = () => {
setLogoCacheBuster(Math.floor(Math.random() * 100)); // Force logo to re-load
},
onError: (msg: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`));
setSubmitStatus(
createInputStatus(
STATUS_ERROR,
t(Localization.Admin.StatusMessages.thereWasAnError, { message: msg }),
),
);
setLoading(false);
},
});
+9 -1
View File
@@ -1,5 +1,6 @@
import { Popconfirm, Button, Typography } from 'antd';
import { FC, useContext, useState } from 'react';
import { useTranslation } from 'next-export-i18n';
import { AlertMessageContext } from '../../utils/alert-message-context';
import { API_YP_RESET, fetchData } from '../../utils/apis';
@@ -10,10 +11,12 @@ import {
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { Localization } from '../../types/localization';
import { FormStatusIndicator } from './FormStatusIndicator';
// eslint-disable-next-line import/prefer-default-export
export const ResetYP: FC = () => {
const { t } = useTranslation();
const { setMessage } = useContext(AlertMessageContext);
const [submitStatus, setSubmitStatus] = useState(null);
@@ -32,7 +35,12 @@ export const ResetYP: FC = () => {
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
} catch (error) {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${error}`));
setSubmitStatus(
createInputStatus(
STATUS_ERROR,
t(Localization.Admin.StatusMessages.thereWasAnError, { message: `${error}` }),
),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}
};
+9 -1
View File
@@ -4,6 +4,7 @@
import React, { useState, useContext, FC } from 'react';
import { Switch } from 'antd';
import { useTranslation } from 'next-export-i18n';
import {
createInputStatus,
StatusState,
@@ -11,6 +12,7 @@ import {
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { Localization } from '../../types/localization';
import { FormStatusIndicator } from './FormStatusIndicator';
import { RESET_TIMEOUT, postConfigUpdateToAPI } from '../../utils/config-constants';
@@ -42,6 +44,7 @@ export const ToggleSwitch: FC<ToggleSwitchProps> = ({
useSubmit,
onChange,
}) => {
const { t } = useTranslation();
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
let resetTimer = null;
@@ -68,7 +71,12 @@ export const ToggleSwitch: FC<ToggleSwitchProps> = ({
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${message}`));
setSubmitStatus(
createInputStatus(
STATUS_ERROR,
t(Localization.Admin.StatusMessages.thereWasAnError, { message }),
),
);
},
});
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
+9 -1
View File
@@ -1,5 +1,6 @@
import React, { useContext, useState, useEffect, FC } from 'react';
import { Typography, Slider } from 'antd';
import { useTranslation } from 'next-export-i18n';
import { ServerStatusContext } from '../../utils/server-status-context';
import { AlertMessageContext } from '../../utils/alert-message-context';
import {
@@ -14,6 +15,7 @@ import {
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { Localization } from '../../types/localization';
import { FormStatusIndicator } from './FormStatusIndicator';
const { Title } = Typography;
@@ -36,6 +38,7 @@ const SLIDER_COMMENTS = {
// eslint-disable-next-line import/prefer-default-export
export const VideoLatency: FC = () => {
const { t } = useTranslation();
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [selectedOption, setSelectedOption] = useState(null);
@@ -73,7 +76,12 @@ export const VideoLatency: FC = () => {
value: postValue,
path: 'videoSettings',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Latency buffer level updated.'));
setSubmitStatus(
createInputStatus(
STATUS_SUCCESS,
t(Localization.Admin.StatusMessages.latencyBufferUpdated),
),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
if (serverStatusData.online) {
@@ -3,6 +3,7 @@ import React, { FC, useContext, useCallback, useEffect, useState } from 'react';
import { Button, Col, Collapse, Row, Slider, Space } from 'antd';
import Paragraph from 'antd/lib/typography/Paragraph';
import Title from 'antd/lib/typography/Title';
import { useTranslation } from 'next-export-i18n';
import { EditCustomStyles } from '../../EditCustomStyles';
import s from './appearance.module.scss';
import { postConfigUpdateToAPI, RESET_TIMEOUT } from '../../../../utils/config-constants';
@@ -12,6 +13,7 @@ import {
STATUS_ERROR,
STATUS_SUCCESS,
} from '../../../../utils/input-statuses';
import { Localization } from '../../../../types/localization';
import { ServerStatusContext } from '../../../../utils/server-status-context';
import { FormStatusIndicator } from '../../FormStatusIndicator';
@@ -122,6 +124,7 @@ const ColorCollection: FC<ColorCollectionProps> = ({ variables, updateColor }) =
// eslint-disable-next-line react/function-component-definition
export default function Appearance() {
const { t } = useTranslation();
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData;
const { instanceDetails } = serverConfig;
@@ -179,7 +182,9 @@ export default function Appearance() {
apiPath: ENDPOINT,
data: { value: {} },
onSuccess: () => {
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
setSubmitStatus(
createInputStatus(STATUS_SUCCESS, t(Localization.Admin.StatusMessages.updated)),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
setCustomValues({});
},
@@ -200,7 +205,9 @@ export default function Appearance() {
apiPath: ENDPOINT,
data: { value: c },
onSuccess: () => {
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
setSubmitStatus(
createInputStatus(STATUS_SUCCESS, t(Localization.Admin.StatusMessages.updated)),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
setFieldInConfigState({
@@ -1,6 +1,7 @@
/* eslint-disable react/no-array-index-key */
import React, { useContext, useState, useEffect } from 'react';
import { Typography, Tag } from 'antd';
import { useTranslation } from 'next-export-i18n';
import { ServerStatusContext } from '../../../../utils/server-status-context';
import {
FIELD_PROPS_TAGS,
@@ -17,12 +18,14 @@ import {
STATUS_SUCCESS,
STATUS_WARNING,
} from '../../../../utils/input-statuses';
import { Localization } from '../../../../types/localization';
import { TAG_COLOR } from '../../EditValueArray';
const { Title } = Typography;
// eslint-disable-next-line react/function-component-definition
export default function EditInstanceTags() {
const { t } = useTranslation();
const [newTagInput, setNewTagInput] = useState<string>('');
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
@@ -58,7 +61,9 @@ export default function EditInstanceTags() {
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({ fieldName: 'tags', value: postValue, path: configPath });
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Tags updated.'));
setSubmitStatus(
createInputStatus(STATUS_SUCCESS, t(Localization.Admin.StatusMessages.tagsUpdated)),
);
setNewTagInput('');
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
@@ -81,11 +86,15 @@ export default function EditInstanceTags() {
resetStates();
const newTag = newTagInput.trim();
if (newTag === '') {
setSubmitStatus(createInputStatus(STATUS_WARNING, 'Please enter a tag'));
setSubmitStatus(
createInputStatus(STATUS_WARNING, t(Localization.Admin.StatusMessages.pleaseEnterTag)),
);
return;
}
if (tags.some(tag => tag.toLowerCase() === newTag.toLowerCase())) {
setSubmitStatus(createInputStatus(STATUS_WARNING, 'This tag is already used!'));
setSubmitStatus(
createInputStatus(STATUS_WARNING, t(Localization.Admin.StatusMessages.tagAlreadyUsed)),
);
return;
}
@@ -1,6 +1,7 @@
import { Button, Collapse } from 'antd';
import classNames from 'classnames';
import React, { useContext, useState, useEffect } from 'react';
import { useTranslation } from 'next-export-i18n';
import { UpdateArgs } from '../../../../types/config-section';
import { ServerStatusContext } from '../../../../utils/server-status-context';
import { AlertMessageContext } from '../../../../utils/alert-message-context';
@@ -18,6 +19,7 @@ import {
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../../../utils/input-statuses';
import { Localization } from '../../../../types/localization';
import { TextField } from '../../TextField';
import { FormStatusIndicator } from '../../FormStatusIndicator';
import { isValidUrl } from '../../../../utils/validators';
@@ -56,6 +58,7 @@ function checkSaveable(formValues: any, currentValues: any) {
// eslint-disable-next-line react/function-component-definition
export default function EditStorage() {
const { t } = useTranslation();
const [formDataValues, setFormDataValues] = useState(null);
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
@@ -125,7 +128,9 @@ export default function EditStorage() {
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({ fieldName: 's3', value: postValue, path: '' });
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
setSubmitStatus(
createInputStatus(STATUS_SUCCESS, t(Localization.Admin.StatusMessages.updated)),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
setAlertMessage(
'Changing your storage configuration will take place the next time you start a new stream.',
@@ -1,5 +1,6 @@
import { Button, Typography } from 'antd';
import React, { useState, useContext, useEffect } from 'react';
import { useTranslation } from 'next-export-i18n';
import { ServerStatusContext } from '../../../utils/server-status-context';
import { TextField, TEXTFIELD_TYPE_TEXTAREA } from '../TextField';
import {
@@ -14,12 +15,14 @@ import {
STATUS_ERROR,
STATUS_SUCCESS,
} from '../../../utils/input-statuses';
import { Localization } from '../../../types/localization';
import { UpdateArgs } from '../../../types/config-section';
import { FormStatusIndicator } from '../FormStatusIndicator';
const { Title } = Typography;
export const BrowserNotify = () => {
const { t } = useTranslation();
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { notifications } = serverConfig || {};
@@ -77,7 +80,9 @@ export const BrowserNotify = () => {
value: postValue,
path: 'notifications',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
setSubmitStatus(
createInputStatus(STATUS_SUCCESS, t(Localization.Admin.StatusMessages.updated)),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
@@ -1,5 +1,6 @@
import { Button, Typography } from 'antd';
import React, { useState, useContext, useEffect } from 'react';
import { useTranslation } from 'next-export-i18n';
import { ServerStatusContext } from '../../../utils/server-status-context';
import { TextField } from '../TextField';
import { FormStatusIndicator } from '../FormStatusIndicator';
@@ -15,11 +16,13 @@ import {
STATUS_ERROR,
STATUS_SUCCESS,
} from '../../../utils/input-statuses';
import { Localization } from '../../../types/localization';
import { UpdateArgs } from '../../../types/config-section';
const { Title } = Typography;
export const DiscordNotify = () => {
const { t } = useTranslation();
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { notifications } = serverConfig || {};
@@ -77,7 +80,9 @@ export const DiscordNotify = () => {
value: postValue,
path: 'notifications',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
setSubmitStatus(
createInputStatus(STATUS_SUCCESS, t(Localization.Admin.StatusMessages.updated)),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
+52 -1
View File
@@ -83,7 +83,58 @@
"emojiPageDescription": "Here you can upload new custom emojis for usage in the chat. When uploading a new emoji, the filename without extension will be used as emoji name. Additionally, emoji names are case-insensitive. For best results, ensure all emoji have unique names.",
"emojiUploadBulkGuide": "Want to upload custom emojis in bulk? Check out our <a href=\"https://owncast.online/docs/chat/emoji\" rel=\"noopener noreferrer\" target=\"_blank\">Emoji guide</a>.",
"emojis": "Emojis",
"uploadNewEmoji": "Upload new emoji"
"uploadNewEmoji": "Upload new emoji",
"StatusMessages": {
"updated": "Updated.",
"tagsUpdated": "Tags updated.",
"variantsUpdated": "Variants updated",
"videoCodecUpdated": "Video codec updated.",
"latencyBufferUpdated": "Latency buffer level updated.",
"deletingEmoji": "Deleting emoji...",
"emojiDeleted": "Emoji deleted",
"convertingEmoji": "Converting emoji...",
"uploadingEmoji": "Uploading emoji...",
"emojiUploadedSuccessfully": "Emoji uploaded successfully!",
"thereWasAnError": "There was an error: {{message}}",
"fileSizeTooBig": "File size is too big: {{size}}",
"fileTypeNotSupported": "File type is not supported: {{type}}",
"pleaseEnterTag": "Please enter a tag",
"tagAlreadyUsed": "This tag is already used!"
},
"Actions": {
"title": "External Actions",
"description": "External action URLs are 3rd party UI you can display, embedded, into your Owncast page when a user clicks on a button to launch your action.",
"readMoreLink": "Read more about how to use actions, with examples, at",
"createNewAction": "Create New Action",
"createNewActionTitle": "Create New Action",
"editActionTitle": "Edit Action",
"modalDescription": "Add the URL for the external action you want to present.",
"onlyHttpsSupported": "Only HTTPS URLs and embeds are supported.",
"readMoreAboutActions": "Read more about external actions.",
"selectActionType": "Select an action type",
"linkOrEmbedUrl": "Link or embed an URL",
"customHtml": "Custom HTML",
"htmlEmbedPlaceholder": "HTML embed code (required)",
"urlPlaceholder": "https://myserver.com/action (required)",
"titlePlaceholder": "Your action title (required)",
"descriptionPlaceholder": "Optional description",
"iconPlaceholder": "https://myserver.com/action/icon.png (optional)",
"optionalBackgroundColor": "Optional background color of the action button.",
"openExternally": "Open in a new tab instead of within your page."
},
"Webhooks": {
"createNewWebhook": "Create New Webhook",
"webhookUrlPlaceholder": "https://myserver.com/webhook",
"selectEvents": "Select the events that will be sent to this webhook.",
"selectAll": "Select all"
},
"AccessTokens": {
"createNewAccessToken": "Create New Access token",
"nameDescription": "The name will be displayed as the chat user when sending messages with this access token.",
"namePlaceholder": "Name of bot, service, or integration",
"selectPermissions": "Select the permissions this access token will have. It cannot be edited after it's created.",
"selectAll": "Select all"
}
},
"Common": {
"poweredByOwncastVersion": "Powered by <a href='https://owncast.online'>Owncast v{{versionNumber}}</a>"
+249 -76
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -16,12 +16,15 @@ import {
import { format } from 'date-fns';
import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import {
fetchData,
ACCESS_TOKENS,
DELETE_ACCESS_TOKEN,
CREATE_ACCESS_TOKEN,
} from '../../utils/apis';
import { Localization } from '../../types/localization';
import { Translation } from '../../components/ui/Translation/Translation';
import { AdminLayout } from '../../components/layouts/AdminLayout';
@@ -72,6 +75,7 @@ interface Props {
}
const NewTokenModal = (props: Props) => {
const { onOk, onCancel, open } = props;
const { t } = useTranslation();
const [selectedScopes, setSelectedScopes] = useState([]);
const [name, setName] = useState('');
@@ -107,7 +111,7 @@ const NewTokenModal = (props: Props) => {
return (
<Modal
title="Create New Access token"
title={t(Localization.Admin.AccessTokens.createNewAccessToken)}
open={open}
onOk={saveToken}
onCancel={onCancel}
@@ -115,18 +119,17 @@ const NewTokenModal = (props: Props) => {
>
<p>
<p>
The name will be displayed as the chat user when sending messages with this access token.
<Translation translationKey={Localization.Admin.AccessTokens.nameDescription} />
</p>
<Input
value={name}
placeholder="Name of bot, service, or integration"
placeholder={t(Localization.Admin.AccessTokens.namePlaceholder)}
onChange={input => setName(input.currentTarget.value)}
/>
</p>
<p>
Select the permissions this access token will have. It cannot be edited after it&apos;s
created.
<Translation translationKey={Localization.Admin.AccessTokens.selectPermissions} />
</p>
<Checkbox.Group style={{ width: '100%' }} value={selectedScopes} onChange={onChange}>
<Row>{checkboxes}</Row>
@@ -134,7 +137,7 @@ const NewTokenModal = (props: Props) => {
<p>
<Button type="primary" onClick={selectAll}>
Select all
<Translation translationKey={Localization.Admin.AccessTokens.selectAll} />
</Button>
</p>
</Modal>
+34 -20
View File
@@ -4,6 +4,7 @@ import { bbedit } from '@uiw/codemirror-theme-bbedit';
import { html as codeMirrorHTML } from '@codemirror/lang-html';
import dynamic from 'next/dynamic';
import React, { ReactElement, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'next-export-i18n';
import { FormStatusIndicator } from '../../components/admin/FormStatusIndicator';
import { ExternalAction } from '../../interfaces/external-action';
import {
@@ -14,6 +15,8 @@ import {
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
import { ServerStatusContext } from '../../utils/server-status-context';
import { isValidUrl, DEFAULT_TEXTFIELD_URL_PATTERN } from '../../utils/validators';
import { Localization } from '../../types/localization';
import { Translation } from '../../components/ui/Translation/Translation';
import { AdminLayout } from '../../components/layouts/AdminLayout';
@@ -54,6 +57,7 @@ type ActionType = 'url' | 'html';
const ActionModal = (props: Props) => {
const { onOk, onCancel, open, action } = props;
const { t } = useTranslation();
const [actionType, setActionType] = useState<ActionType>('url');
@@ -120,32 +124,38 @@ const ActionModal = (props: Props) => {
return (
<Modal
destroyOnClose
title={action == null ? 'Create New Action' : 'Edit Action'}
title={
action == null
? t(Localization.Admin.Actions.createNewActionTitle)
: t(Localization.Admin.Actions.editActionTitle)
}
open={open}
onOk={save}
onCancel={onCancel}
okButtonProps={okButtonProps}
>
<Form initialValues={action}>
Add the URL for the external action you want to present.{' '}
<strong>Only HTTPS URLs and embeds are supported.</strong>
<Translation translationKey={Localization.Admin.Actions.modalDescription} />{' '}
<strong>
<Translation translationKey={Localization.Admin.Actions.onlyHttpsSupported} />
</strong>
<p>
<a
href="https://owncast.online/thirdparty/actions/"
target="_blank"
rel="noopener noreferrer"
>
Read more about external actions.
<Translation translationKey={Localization.Admin.Actions.readMoreAboutActions} />
</a>
</p>
<Form.Item>
<Select
value={actionType}
onChange={setActionType}
placeholder="Select an action type"
placeholder={t(Localization.Admin.Actions.selectActionType)}
options={[
{ label: 'Link or embed an URL', value: 'url' },
{ label: 'Custom HTML', value: 'html' },
{ label: t(Localization.Admin.Actions.linkOrEmbedUrl), value: 'url' },
{ label: t(Localization.Admin.Actions.customHtml), value: 'html' },
]}
/>
</Form.Item>
@@ -153,7 +163,7 @@ const ActionModal = (props: Props) => {
<Form.Item name="html">
<CodeMirror
value={actionHTML}
placeholder="HTML embed code (required)"
placeholder={t(Localization.Admin.Actions.htmlEmbedPlaceholder)}
theme={bbedit}
height="200px"
extensions={[codeMirrorHTML()]}
@@ -164,7 +174,7 @@ const ActionModal = (props: Props) => {
<Form.Item name="url">
<Input
required
placeholder="https://myserver.com/action (required)"
placeholder={t(Localization.Admin.Actions.urlPlaceholder)}
onChange={input => setActionUrl(input.currentTarget.value.trim())}
type="url"
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
@@ -175,21 +185,21 @@ const ActionModal = (props: Props) => {
<Input
value={actionTitle}
required
placeholder="Your action title (required)"
placeholder={t(Localization.Admin.Actions.titlePlaceholder)}
onChange={input => setActionTitle(input.currentTarget.value)}
/>
</Form.Item>
<Form.Item name="description">
<Input
value={actionDescription}
placeholder="Optional description"
placeholder={t(Localization.Admin.Actions.descriptionPlaceholder)}
onChange={input => setActionDescription(input.currentTarget.value)}
/>
</Form.Item>
<Form.Item name="icon">
<Input
value={actionIcon}
placeholder="https://myserver.com/action/icon.png (optional)"
placeholder={t(Localization.Admin.Actions.iconPlaceholder)}
onChange={input => setActionIcon(input.currentTarget.value)}
/>
</Form.Item>
@@ -201,7 +211,7 @@ const ActionModal = (props: Props) => {
onChange={input => setActionColor(input.currentTarget.value)}
/>
</Form.Item>
Optional background color of the action button.
<Translation translationKey={Localization.Admin.Actions.optionalBackgroundColor} />
</div>
{actionType === 'html' ? null : (
<Form.Item name="openExternally">
@@ -210,7 +220,7 @@ const ActionModal = (props: Props) => {
defaultChecked={openExternally}
onChange={onOpenExternallyChanged}
>
Open in a new tab instead of within your page.
<Translation translationKey={Localization.Admin.Actions.openExternally} />
</Checkbox>
</Form.Item>
)}
@@ -220,6 +230,7 @@ const ActionModal = (props: Props) => {
};
const Actions = () => {
const { t } = useTranslation();
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { externalActions } = serverConfig;
@@ -245,7 +256,9 @@ const Actions = () => {
data: { value: actionsData },
onSuccess: () => {
setFieldInConfigState({ fieldName: 'externalActions', value: actionsData, path: '' });
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
setSubmitStatus(
createInputStatus(STATUS_SUCCESS, t(Localization.Admin.StatusMessages.updated)),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
@@ -401,13 +414,14 @@ const Actions = () => {
return (
<div>
<Title>External Actions</Title>
<Title>
<Translation translationKey={Localization.Admin.Actions.title} />
</Title>
<Paragraph>
External action URLs are 3rd party UI you can display, embedded, into your Owncast page when
a user clicks on a button to launch your action.
<Translation translationKey={Localization.Admin.Actions.description} />
</Paragraph>
<Paragraph>
Read more about how to use actions, with examples, at{' '}
<Translation translationKey={Localization.Admin.Actions.readMoreLink} />{' '}
<a
href="https://owncast.online/thirdparty/?source=admin"
target="_blank"
@@ -426,7 +440,7 @@ const Actions = () => {
/>
<br />
<Button type="primary" onClick={showCreateModal}>
Create New Action
<Translation translationKey={Localization.Admin.Actions.createNewAction} />
</Button>
<FormStatusIndicator status={submitStatus} />
+18 -5
View File
@@ -67,7 +67,9 @@ const Emoji = () => {
setLoading(true);
setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Deleting emoji...'));
setSubmitStatus(
createInputStatus(STATUS_PROCESSING, t(Localization.Admin.StatusMessages.deletingEmoji)),
);
try {
const response = await fetchData(DELETE_EMOJI, {
@@ -79,7 +81,9 @@ const Emoji = () => {
throw response;
}
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Emoji deleted'));
setSubmitStatus(
createInputStatus(STATUS_SUCCESS, t(Localization.Admin.StatusMessages.emojiDeleted)),
);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
} catch (error) {
setSubmitStatus(createInputStatus(STATUS_ERROR, `${error}`));
@@ -93,7 +97,9 @@ const Emoji = () => {
async function handleEmojiUpload() {
setLoading(true);
try {
setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Converting emoji...'));
setSubmitStatus(
createInputStatus(STATUS_PROCESSING, t(Localization.Admin.StatusMessages.convertingEmoji)),
);
// eslint-disable-next-line consistent-return
const emojiData = await new Promise<CustomEmoji>((res, rej) => {
@@ -111,7 +117,9 @@ const Emoji = () => {
);
});
setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Uploading emoji...'));
setSubmitStatus(
createInputStatus(STATUS_PROCESSING, t(Localization.Admin.StatusMessages.uploadingEmoji)),
);
const response = await fetchData(UPLOAD_EMOJI, {
method: 'POST',
@@ -125,7 +133,12 @@ const Emoji = () => {
throw response;
}
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Emoji uploaded successfully!'));
setSubmitStatus(
createInputStatus(
STATUS_SUCCESS,
t(Localization.Admin.StatusMessages.emojiUploadedSuccessfully),
),
);
getEmojis();
} catch (error) {
setSubmitStatus(createInputStatus(STATUS_ERROR, `${error}`));
+10 -4
View File
@@ -14,8 +14,11 @@ import {
} from 'antd';
import dynamic from 'next/dynamic';
import React, { ReactElement, useEffect, useState } from 'react';
import { useTranslation } from 'next-export-i18n';
import { CREATE_WEBHOOK, DELETE_WEBHOOK, fetchData, WEBHOOKS } from '../../utils/apis';
import { isValidUrl, DEFAULT_TEXTFIELD_URL_PATTERN } from '../../utils/validators';
import { Localization } from '../../types/localization';
import { Translation } from '../../components/ui/Translation/Translation';
import { AdminLayout } from '../../components/layouts/AdminLayout';
@@ -71,6 +74,7 @@ interface Props {
const NewWebhookModal = (props: Props) => {
const { onOk, onCancel, open } = props;
const { t } = useTranslation();
const [selectedEvents, setSelectedEvents] = useState([]);
const [webhookUrl, setWebhookUrl] = useState('');
@@ -108,7 +112,7 @@ const NewWebhookModal = (props: Props) => {
return (
<Modal
title="Create New Webhook"
title={t(Localization.Admin.Webhooks.createNewWebhook)}
open={open}
onOk={save}
onCancel={onCancel}
@@ -117,20 +121,22 @@ const NewWebhookModal = (props: Props) => {
<div>
<Input
value={webhookUrl}
placeholder="https://myserver.com/webhook"
placeholder={t(Localization.Admin.Webhooks.webhookUrlPlaceholder)}
onChange={input => setWebhookUrl(input.currentTarget.value.trim())}
type="url"
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
/>
</div>
<p>Select the events that will be sent to this webhook.</p>
<p>
<Translation translationKey={Localization.Admin.Webhooks.selectEvents} />
</p>
<Checkbox.Group style={{ width: '100%' }} value={selectedEvents} onChange={onChange}>
<Row>{checkboxes}</Row>
</Checkbox.Group>
<p>
<Button type="primary" onClick={selectAll}>
Select all
<Translation translationKey={Localization.Admin.Webhooks.selectAll} />
</Button>
</p>
</Modal>
+60
View File
@@ -250,6 +250,66 @@ export const Localization = {
timestamp: 'Admin.timestamp',
message: 'Admin.message',
logs: 'Admin.logs',
// Form status messages
StatusMessages: {
updated: 'Admin.StatusMessages.updated',
tagsUpdated: 'Admin.StatusMessages.tagsUpdated',
variantsUpdated: 'Admin.StatusMessages.variantsUpdated',
videoCodecUpdated: 'Admin.StatusMessages.videoCodecUpdated',
latencyBufferUpdated: 'Admin.StatusMessages.latencyBufferUpdated',
deletingEmoji: 'Admin.StatusMessages.deletingEmoji',
emojiDeleted: 'Admin.StatusMessages.emojiDeleted',
convertingEmoji: 'Admin.StatusMessages.convertingEmoji',
uploadingEmoji: 'Admin.StatusMessages.uploadingEmoji',
emojiUploadedSuccessfully: 'Admin.StatusMessages.emojiUploadedSuccessfully',
thereWasAnError: 'Admin.StatusMessages.thereWasAnError',
fileSizeTooBig: 'Admin.StatusMessages.fileSizeTooBig',
fileTypeNotSupported: 'Admin.StatusMessages.fileTypeNotSupported',
pleaseEnterTag: 'Admin.StatusMessages.pleaseEnterTag',
tagAlreadyUsed: 'Admin.StatusMessages.tagAlreadyUsed',
},
// Actions page
Actions: {
title: 'Admin.Actions.title',
description: 'Admin.Actions.description',
readMoreLink: 'Admin.Actions.readMoreLink',
createNewAction: 'Admin.Actions.createNewAction',
createNewActionTitle: 'Admin.Actions.createNewActionTitle',
editActionTitle: 'Admin.Actions.editActionTitle',
modalDescription: 'Admin.Actions.modalDescription',
onlyHttpsSupported: 'Admin.Actions.onlyHttpsSupported',
readMoreAboutActions: 'Admin.Actions.readMoreAboutActions',
selectActionType: 'Admin.Actions.selectActionType',
linkOrEmbedUrl: 'Admin.Actions.linkOrEmbedUrl',
customHtml: 'Admin.Actions.customHtml',
htmlEmbedPlaceholder: 'Admin.Actions.htmlEmbedPlaceholder',
urlPlaceholder: 'Admin.Actions.urlPlaceholder',
titlePlaceholder: 'Admin.Actions.titlePlaceholder',
descriptionPlaceholder: 'Admin.Actions.descriptionPlaceholder',
iconPlaceholder: 'Admin.Actions.iconPlaceholder',
optionalBackgroundColor: 'Admin.Actions.optionalBackgroundColor',
openExternally: 'Admin.Actions.openExternally',
},
// Webhooks page
Webhooks: {
createNewWebhook: 'Admin.Webhooks.createNewWebhook',
webhookUrlPlaceholder: 'Admin.Webhooks.webhookUrlPlaceholder',
selectEvents: 'Admin.Webhooks.selectEvents',
selectAll: 'Admin.Webhooks.selectAll',
},
// Access Tokens page
AccessTokens: {
createNewAccessToken: 'Admin.AccessTokens.createNewAccessToken',
nameDescription: 'Admin.AccessTokens.nameDescription',
namePlaceholder: 'Admin.AccessTokens.namePlaceholder',
selectPermissions: 'Admin.AccessTokens.selectPermissions',
cannotEditAfterCreated: 'Admin.AccessTokens.cannotEditAfterCreated',
selectAll: 'Admin.AccessTokens.selectAll',
},
},
/**